diff --git a/AGENTS.md b/AGENTS.md index be24e74..481cb04 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,13 +18,22 @@ servidores web no aplica porque no se usa ASP.NET. - **Un share = carpeta + location**, servida a la vez por web, FTP y WebDAV. - Gana la *location* más específica (por segmentos, como las *locations* de prefijo de nginx). - - Si hay índice se sirve; si no, navegador de directorios. Ver `DOC/SHARES-Y-RUTAS.md`. -- **Web en modo clásico** (HTML 3.2/4.01, sin JS, ISO-8859-1 con entidades, GIF) y **modo - moderno** como mejora progresiva. Subidas por formulario multipart. Ver `DOC/WEB.md`. -- **FTP** activo/pasivo con juegos de caracteres antiguos; **WebDAV** clase 1 + LOCK simulado. - Ver `DOC/FTP-Y-WEBDAV.md`. -- **Mac clásico:** MacBinary/BinHex al subir y bajar, forks guardados como AppleDouble (`._`). - Ver `DOC/MAC-CLASICO.md`. + - Si hay índice se sirve; si no, navegador de directorios. `?$list` fuerza el navegador + aunque haya índice. Ver `DOC/SHARES-Y-RUTAS.md`. +- **Web en modo clásico** (HTML 3.2/4.01, sin JS, ISO-8859-1 con entidades, GIF, fuente sin + serifa) y **modo moderno** como mejora progresiva. Subidas por formulario multipart. Ver + `DOC/WEB.md`. +- **Clientes de destino:** DOS, Windows 3.1, 9x, Mac clásico y modernos. Adaptación + **automática** por perfil de cliente (User-Agent, o sufijo `+dos`/`+mac` en el usuario FTP): + alias 8.3 estables, MacBinary, juegos de caracteres. Ver `DOC/CLIENTES.md`. +- **FTP** activo/pasivo con juegos de caracteres antiguos; **WebDAV** clase 1 + LOCK simulado, + activo por defecto. Ver `DOC/FTP-Y-WEBDAV.md`. +- **Mac clásico transparente:** MacBinary/BinHex se decodifican al subir (detección por CRC) y + los forks se guardan como AppleDouble (`._`). `Mac = false` lo desactiva en un share. Ver + `DOC/MAC-CLASICO.md`. +- **De Sunfish a Sunfish:** por WebDAV (cliente en Core) con descubrimiento DNS-SD; sin + protocolo propio. +- **TUI** con el icono del pez luna en medios bloques cuando el terminal es lo bastante grande. - **Panel de administración web** con contraseña, desactivado por defecto. - **Sin TLS a propósito.** Se eliminan la ejecución en el servidor, compartir pantalla, los plugins y el requisito de administrador. Ver `DOC/SEGURIDAD.md` y `DOC/LEGACY-2.0.md`. @@ -32,13 +41,29 @@ Textos en español e inglés (`SUNFISH_LANG`). ## Estado -- **Solo documentación** de la 3. El código de `Sunfish/` y `Sunfish Test Plugin/` es la 2.0 - (.NET Framework 4.5): no se porta y se eliminará al empezar la reescritura. +- **Fases 1 a 7 hechas (todas las previstas):** `Sunfish.Core` (configuración INI con recarga + en caliente, shares/locations, validación de rutas, motor HTTP propio, web con perfiles de + cliente, subidas, carpetas, renombrar, borrar, anti-CSRF, HTTP Basic, ZIP en flujo, servidor + FTP, WebDAV, cliente WebDAV, DNS-SD, Mac clásico con MacBinary/BinHex/AppleDouble, alias 8.3, + panel de administración web, importación de la 2.0, QR), `sunfish-cli` (TUI con pestaña + Explorar y QR, `serve`, modo rápido, `share`, `user`, `admin`, `import-v2`) y `Sunfish.exe` + (WinForms, con ventanas Explorar, QR y panel web). Scripts `build.*` y `run.*`. + `dotnet test` pasa (252 pruebas). Lo que falta en `DOC/COMPILACION-Y-PRUEBAS.md`. +- El panel web (`Web/WebAdmin.cs`) usa el `SunfishHost` que recibe `WebApp`; no cambia el HTTP + (reiniciarlo cortaría la propia petición). +- `SunfishHost` arranca y para HTTP y FTP por separado (`Server.Http` / `Server.Ftp`) y les da el + mismo `Authenticator` (bloqueo por intentos compartido). +- Las aplicaciones editan una copia (`SunfishConfig.Clone`) y la aplican con + `SunfishHost.SaveAndApplyAsync`; las validaciones compartidas están en `Config/ShareSettings.cs`. +- Operaciones de disco comunes (para web, FTP y WebDAV) en `Shares/FileOperations.cs`. +- El código de la 2.0 se retiró del árbol; queda en la etiqueta `2.0`. El arte original está en + `art/`. - **Remoto** actual privado, rama `master`. El README ya apunta a GitHub (rama `main`) para cuando se publique allí. - **Decisiones pendientes** listadas en `DOC/README.md`. ## Compilar -Previsto: `dotnet build`, `dotnet test`, `dotnet run --project src/Sunfish.WinForms` o -`src/Sunfish.Cli`. `build.ps1` compila los dos ejecutables en Windows; `build.sh`, `sunfish-cli` -en Linux/macOS. Ver `DOC/COMPILACION-Y-PRUEBAS.md`. +`dotnet build`, `dotnet test`, `run.cmd` (GUI), `run-cli.cmd` / `run.sh` (consola). +`build.ps1` compila `Sunfish.exe` (autocontenido; `SUNFISH_LIGHT=1` para el ligero) y +`sunfish-cli.exe` en Windows; `build.sh`, `sunfish-cli` en Linux/macOS. Ver +`DOC/COMPILACION-Y-PRUEBAS.md`. diff --git a/DOC/APLICACIONES.md b/DOC/APLICACIONES.md index 916674d..97a9c37 100644 --- a/DOC/APLICACIONES.md +++ b/DOC/APLICACIONES.md @@ -1,7 +1,17 @@ # Aplicaciones: WinForms, consola y futuras -> Diseño previsto; aún no implementado. Todas usan `Sunfish.Core` y el mismo fichero de -> configuración; ninguna contiene lógica de servidor. +> Todas usan `Sunfish.Core` y el mismo fichero de configuración; ninguna contiene lógica de +> servidor. **Hecho (fase 3):** `Sunfish.exe` y la TUI de `sunfish-cli` con shares, usuarios, +> puerto, arranque/parada, registro, direcciones, modo rápido, instancia única (GUI), área de +> notificación (GUI), icono (TUI) y subcomandos `share`/`user`. **Fase 4:** FTP. **Fase 5:** explorar +> otro servidor (ventana en la GUI, pestaña F4 en la TUI). **Fase 7:** código QR (ventana en +> la GUI, tecla C en la TUI), ajustes del panel web (GUI y `sunfish-cli admin`) e importación de +> la 2.0 (al crear la configuración y con `sunfish-cli import-v2`). **Pendiente:** conexiones +> activas, iniciar con Windows y servicio. +> +> Teclas de la TUI: F1–F5 o Tab cambian de pestaña (Estado, Shares, Usuarios, Explorar, Registro); en Shares, A/Ins añade, Enter edita, +> Espacio activa/desactiva, D/Supr quita; en Usuarios, A añade, P cambia la contraseña, D borra; +> en Estado, S arranca/para, P cambia el puerto HTTP, F activa o desactiva FTP, T cambia el puerto FTP, N el nombre, C muestra el código QR de cada dirección (flechas: la siguiente); en Explorar, Enter conecta o abre, A escribe una dirección, R vuelve a buscar, Retroceso sube, D descarga, U sube un fichero o carpeta local, M crea carpeta, Supr borra y Esc vuelve a la lista de servidores; Q/Esc/Ctrl+C salen (con pregunta). ## Lo común a todas @@ -12,6 +22,11 @@ - **Direcciones:** lista de IP locales por interfaz con la URL de cada share (HTTP y FTP), copiable, y un **código QR** de la URL para abrirla desde un móvil. - **Registro:** conexiones, descargas, subidas y errores. +- **Explorar otro servidor:** lista los Sunfish de la red (descubiertos por DNS-SD) y permite + escribir cualquier URL WebDAV. + - Se navega por sus shares y se copian ficheros y carpetas en los dos sentidos. + - Usa el cliente WebDAV de `Sunfish.Core`; no hay protocolo propio (ver + [FTP-Y-WEBDAV.md](FTP-Y-WEBDAV.md#de-sunfish-a-sunfish)). - **Modo rápido:** si se pasa una carpeta como argumento (también **arrastrándola sobre el ejecutable** en el explorador), se comparte al momento. - Se publica en `/` con listado y solo lectura, se arranca el servidor y se muestran las URLs. @@ -20,17 +35,29 @@ ## `Sunfish.exe` (WinForms, Windows) +**Aspecto de la 2.0:** ventana pequeña y vertical, interfaz compacta y botones con imagen (las +imágenes de la 2.0 están en `src/Sunfish.WinForms/Resources/`; las que faltaban, como «−» y +usuarios, se dibujaron en el mismo estilo). Las imágenes se escalan a los PPP de la pantalla. + - **Ventana principal:** - - interruptores HTTP/FTP y puertos; - - lista de shares (arrastrar una carpeta a la lista crea un share con `Location = /`, - como en la 2.0); - - botones de añadir, editar y quitar; - - acceso a usuarios y al panel web. -- **Menú contextual de un share:** copiar URL por cada IP (como en la 2.0), abrir en el - navegador, mostrar QR, abrir la carpeta en el Explorador. -- **Diálogo de share** con todas las opciones y avisos (p. ej. `Upload` + `Listing` publica - HTML subido). -- **Pestaña de registro** y lista de conexiones activas. + - arriba, «Menú raíz» y, en columna, «Web en puerto» y «FTP en puerto» con sus puertos (cada casilla activa o desactiva su servicio; en rojo si no puede escuchar, p. ej. puerto ocupado); + - lista de shares dibujada como en la 2.0: icono de carpeta (gris si está desactivado, con una + flecha verde si admite subidas), nombre y, debajo en pequeño, location, permisos y usuarios. + Arrastrar una carpeta crea un share con `Location = /`; + - abajo, botones con imagen: añadir, quitar, editar, usuarios, información (i) y «Ok», que + manda la ventana al área de notificación sin parar el servidor. +- **Menú contextual de un share** con iconos: abrir en el navegador, copiar URL por cada IP, + abrir la carpeta, editar (en negrita), activar/desactivar y quitar. +- **Diálogo de share** como el editor de la 2.0: estrecho, etiqueta encima de cada campo, + obligatorios en amarillo claro y el aviso de `Upload` + `Listing` en un recuadro amarillo. +- **Ventana Explorar** (botón de la pantalla): servidores de la red encontrados por DNS-SD o una dirección escrita; lista de la carpeta remota con iconos; botones con imagen para subir de nivel, descargar (elige carpeta local), subir (o arrastrar ficheros y carpetas a la lista), crear carpeta y borrar; pide usuario si el servidor lo exige. +- **Ventana de información (i):** «acerca de», direcciones de cada share por interfaz (doble clic + las abre; menú para copiarlas o ver su **código QR**) y registro. +- **Panel web:** enlace «Panel web: activado/desactivado (cambiar)» en la ventana de información; + diálogo con activar, contraseña (vacía: no cambia) y `AllowFrom`. +- **Primer arranque:** al crear la configuración, si hay un `sunfish2` junto al programa o en la + carpeta actual, ofrece importarlo y muestra lo que no se trae. +- **Pendiente:** lista de conexiones activas. - **Icono en el área de notificación:** minimizar a la bandeja; opción de iniciar con Windows (entrada en `HKCU\...\Run`) e iniciar minimizado. - **Una sola instancia por configuración:** abrir otra vez la trae al frente y le pasa los @@ -49,27 +76,39 @@ | `sunfish-cli serve` | Sin interfaz; registro por la salida estándar (para servicios) | | `sunfish-cli ` | Ejecuta y sale | -**TUI** (ANSI/VT, sin bibliotecas externas): pestañas Estado (IP, URLs, QR con medios bloques), -Shares, Usuarios, Conexiones y Registro. Funciona en Windows Terminal, conhost, terminales de -Unix y por SSH. +**TUI** (ANSI/VT, sin bibliotecas externas): pestañas Estado (IP, URLs, QR con la tecla C), +Shares, Usuarios y Registro (Explorar y Conexiones llegarán con sus fases). -**Subcomandos (propuesta):** +A diferencia de la web, la TUI **solo se usa en terminales modernos** (Windows Terminal, conhost +de Windows 10/11, terminales de Linux/macOS, SSH): usa sin reparos caracteres de caja Unicode +(marco con esquinas redondeadas `╭╮╰╯`, pestañas en el borde superior, atajos en el inferior), +`● ○ ☑ ☐ ▸ •` y color de 24 bits (o 256 colores si el terminal no lo anuncia). + +**Icono en la TUI:** +- La pestaña Estado muestra el pez luna de Sunfish dibujado con medios bloques (`▀ ▄ █`) y + color, a partir del mismo dibujo que el icono de la aplicación (unas 16 × 8 celdas). +- **Solo aparece si cabe:** a partir de un tamaño mínimo de terminal (orientativo: 100 × 30). Si + no cabe, se oculta y deja el espacio a la información. +- **Color:** color real si el terminal lo anuncia (`COLORTERM` o Windows Terminal); si no, la + paleta de 256 colores. + +**Subcomandos:** ``` sunfish-cli serve -sunfish-cli show -sunfish-cli share add NOMBRE CARPETA [--location /ruta] [--listing] [--upload] [--delete] ... -sunfish-cli share set NOMBRE OPCION=VALOR ... +sunfish-cli share add NOMBRE CARPETA [Clave=valor ...] (claves de [Share], p. ej. Upload=true) +sunfish-cli share set NOMBRE [--location /ruta] Clave=valor ... sunfish-cli share del NOMBRE sunfish-cli share list sunfish-cli user add NOMBRE (pide la contraseña) sunfish-cli user passwd NOMBRE sunfish-cli user del NOMBRE -sunfish-cli admin enable|disable -sunfish-cli admin passwd -sunfish-cli import-v2 [FICHERO] +sunfish-cli user list +sunfish-cli admin status | enable | disable | passwd (panel web; enable pide contraseña si no hay) +sunfish-cli admin allow 127.0.0.1 ::1 192.168.1.0/24 (AllowFrom; * = cualquiera) +sunfish-cli import-v2 [FICHERO] (sunfish2 de la 2.0; añade sus shares a la configuración) -Opciones globales: -c, --config FICHERO -l, --log FICHERO --lang es|en +Opciones globales: -c, --config FICHERO -p, --port N -v, --verbose --lang es|en ``` Como servicio: unidad de systemd o `launchd` con `serve` (ejemplos en el repositorio cuando @@ -86,8 +125,14 @@ ## QR y direcciones -- **QR propio** en `Sunfish.Core` (sin dependencias): devuelve una matriz de módulos. - - WinForms la pinta en un `Bitmap`. - - La TUI la dibuja con `▀ ▄ █`. +- **QR propio** en `Sunfish.Core/Qr` (sin dependencias): modo byte (UTF-8), versiones 1–40, + nivel de corrección M como mínimo (sube a Q o H si cabe en la misma versión) y máscara elegida + por penalización. Devuelve una matriz de módulos. + - WinForms la pinta en un `Bitmap` (píxeles exactos, margen de 4 módulos). + - La TUI la dibuja con `▀ ▄ █` en negro sobre fondo blanco (sobre el fondo oscuro del terminal + los móviles no lo leen) y margen de 2 módulos; si no cabe, muestra la dirección. + - El panel web la sirve como PNG de 1 bit (`QrImage.Png`). + - Las pruebas lo comprueban leyéndolo con un decodificador independiente (ZXing.Net, solo en + el proyecto de pruebas). - **Direcciones:** se listan solo las interfaces activas con IPv4 o IPv6 globales/locales, indicando el nombre y el tipo de la interfaz, como hacía la 2.0. diff --git a/DOC/ARQUITECTURA.md b/DOC/ARQUITECTURA.md index c7b5e16..5311540 100644 --- a/DOC/ARQUITECTURA.md +++ b/DOC/ARQUITECTURA.md @@ -57,12 +57,13 @@ Para `Sunfish.exe` hay dos variantes: - **Autocontenida** (`SelfContained`, `EnableCompressionInSingleFile`): incluye el runtime de - .NET y funciona en cualquier Windows 10/11 sin instalar nada. Es la más portable, pero grande - (del orden de decenas de MB; WinForms no admite *trimming*). -- **Dependiente del framework:** unos pocos MB, pero necesita el .NET Desktop Runtime 10. + .NET y funciona en cualquier Windows 10/11 sin instalar nada. Es la más portable, pero grande: + **~49 MB** (WinForms no admite *trimming*). Es la que genera `build.ps1` por defecto. +- **Dependiente del framework** (`-p:SelfContained=false`, o `SUNFISH_LIGHT=1` en `build.ps1`): + **~0,5 MB**, pero necesita el .NET Desktop Runtime 10. -**Pendiente:** medir el tamaño real de las dos y fijar cuál genera `build.ps1` por defecto -(propuesta: autocontenida, con una variable de entorno para la ligera). +`build.ps1` exige las herramientas de C++ (para `sunfish-cli`) y falla con el comando para +instalarlas si no están, como el resto de proyectos. `sunfish-cli` es la opción más portable de verdad: nativo y pequeño en los tres sistemas. @@ -80,6 +81,9 @@ | `WebDav` | Clase 1 + LOCK simulado sobre el mismo motor HTTP | [FTP-Y-WEBDAV.md](FTP-Y-WEBDAV.md) | | `Ftp` | Servidor FTP: control, datos activo/pasivo, listados, juegos de caracteres | [FTP-Y-WEBDAV.md](FTP-Y-WEBDAV.md) | | `Mac` | MacBinary I/II/III, BinHex 4.0, AppleDouble, tabla de tipo/creador | [MAC-CLASICO.md](MAC-CLASICO.md) | +| `Clients` | Perfiles de cliente por User-Agent o por sesión FTP; alias 8.3 estables | [CLIENTES.md](CLIENTES.md) | +| `DavClient` | Cliente WebDAV para explorar otros servidores | [FTP-Y-WEBDAV.md](FTP-Y-WEBDAV.md#de-sunfish-a-sunfish) | +| `Discovery` | Anuncio y búsqueda DNS-SD sobre mDNS | [FTP-Y-WEBDAV.md](FTP-Y-WEBDAV.md#de-sunfish-a-sunfish) | | `Zip` | Escritor ZIP en flujo, compatible con descompresores antiguos | [WEB.md](WEB.md#descargar-carpetas-en-zip) | | `Qr` | Generador de códigos QR propio (para la URL; lo pintan la GUI y la TUI) | [APLICACIONES.md](APLICACIONES.md) | | `Net` | Lista de IP locales por interfaz, URLs por share | [APLICACIONES.md](APLICACIONES.md) | diff --git a/DOC/CLIENTES.md b/DOC/CLIENTES.md new file mode 100644 index 0000000..eae1317 --- /dev/null +++ b/DOC/CLIENTES.md @@ -0,0 +1,81 @@ +# Clientes de destino y adaptación automática + +> Implementado (perfiles, alias 8.3, entrega para Mac). Los clientes concretos están "a probar": la lista +> indica qué se quiere soportar, no qué se ha comprobado. + +Sunfish debe servir igual a un navegador actual que a un PC con DOS o un Mac de 1990. La regla +es que **las adaptaciones sean automáticas**: el servidor detecta el tipo de cliente y ajusta +nombres, formato y codificación sin que haya que configurar nada en el share. Solo hay opciones +para **desactivarlas** cuando estorban. + +## Plataformas de destino + +| Plataforma | Red | Web | FTP | Otros | +|---|---|---|---|---| +| **DOS** | *Packet driver* + pila propia de cada programa | Navegadores gráficos y de texto para DOS | Clientes FTP de DOS (las suites de red para DOS suelen traer uno) | Descargadores HTTP/1.0 de línea de comandos | +| **Windows 3.1 / WfW 3.11** | Pila Winsock de terceros, o TCP/IP-32 de Microsoft en WfW 3.11 | Navegadores de 16 bits (Netscape, IE, Opera, Mosaic) | Clientes FTP de 16 bits; `ftp://` en los navegadores | — | +| **Windows 95/98/ME** | De serie | IE 3–6, Netscape 3–4, Opera | `ftp.exe` (modo activo) | Carpetas web (WebDAV) con IE5/Office | +| **Mac clásico** (System 6 a Mac OS 9) | MacTCP / Open Transport | Navegadores de 68k y PPC de la época | Clientes FTP gráficos (con MacBinary) | Clientes WebDAV de Mac OS 9 | +| **Moderno** | — | Cualquier navegador actual | Cualquier cliente | WebDAV desde Explorador, Finder, GVfs; otro Sunfish | + +## Detección (web) + +Por el `User-Agent` de cada petición se asigna un **perfil**: + +| Perfil | Se reconoce por (orientativo) | Adaptaciones | +|---|---|---| +| `dos` | Navegadores de DOS conocidos, `DOS`, `FreeDOS` | Nombres 8.3, CP437/CP850, modo clásico, diseño apto para texto | +| `win16` | `Win16`, `Windows 3.1` | Nombres 8.3, Windows-1252, modo clásico | +| `mac` | `Mac_68000`, `Mac_PowerPC`, `68K`, `PPC` con Mac OS clásico, navegadores de Mac clásico | MacBinary automático, MacRoman, nombres de hasta 31 caracteres, modo clásico | +| `text` | `Lynx`, `Links`, `w3m` | Diseño para texto (sin depender de tablas ni imágenes) | +| `classic` | Cualquier otro navegador no moderno | Modo clásico (HTML 3.2/4.01, ISO-8859-1 con entidades) | +| `modern` | Navegadores actuales | Modo moderno (UTF-8, CSS, JS opcional) | + +- La lista de firmas está en el código y se amplía con lo que se vaya probando. +- **Forzar el perfil:** `?p=dos|win16|mac|text|classic|modern`. Como algunos clientes antiguos + no guardan cookies, el perfil forzado **se propaga en los enlaces** que genera la página. Si + hay cookies, también se guarda en una. +- Un perfil desconocido o dudoso cae siempre en `classic`, que funciona en todos. + +En **FTP** no hay `User-Agent`; ver [FTP-Y-WEBDAV.md](FTP-Y-WEBDAV.md#nombres-cortos-y-mac-en-ftp). + +## Nombres cortos (8.3) para DOS y Windows 3.1 + +DOS y Windows 3.1 solo admiten nombres `NOMBRE.EXT` (8 + 3 caracteres, sin espacios ni la +mayoría de símbolos). Un fichero como `Fotos del viaje.jpeg` no se puede guardar con su nombre. + +Con los perfiles `dos` y `win16` (y en FTP si se activa), Sunfish presenta **alias 8.3**: + +- Si el nombre ya es 8.3 válido, se usa tal cual (DOS no distingue mayúsculas, y así no cambia ningún enlace). +- Si no, se genera un alias **estable**: no cambia aunque se añadan o quiten otros ficheros de la + carpeta. Así un enlace o un listado antiguo no acaba apuntando a otro fichero. + - Base: las 3 primeras letras válidas del nombre, transliteradas a ASCII (`á` → `A`). + - Después, 3 dígitos hexadecimales de un hash del nombre completo, y `~1`. + - Extensión: los 3 primeros caracteres válidos de la última extensión. + - Ejemplo: `Fotos del viaje.jpeg` → `FOTxxx~1.JPE` (`xxx`, el hash). + - Si dos nombres de la misma carpeta dan el mismo alias, el segundo en orden alfabético pasa + a `~2`, y así sucesivamente (muy raro). +- Los alias se usan en el listado, en los enlaces y en la cabecera `Content-Disposition` de las + descargas. En la web se muestra también el nombre largo, para saber qué es cada cosa. +- Al pedir una ruta (web o FTP, con cualquier perfil), cada segmento se busca primero por nombre real y después por alias. +- Lo que sube un cliente DOS o Win16 ya tiene nombre 8.3 y se guarda tal cual. +- Se desactiva por share con `ShortNames = false`. + +## Nombres en Mac clásico + +HFS admite hasta 31 caracteres. Con el perfil `mac`, los nombres más largos se acortan para la +descarga (`Content-Disposition` y nombre dentro de MacBinary) conservando la extensión. Los +ficheros con *resource fork* se descargan en MacBinary de forma automática; ver +[MAC-CLASICO.md](MAC-CLASICO.md). + +## Diseño de página para todos + +- **Modo clásico:** + - Tablas sencillas, sin anidar más de lo necesario. + - Todas las imágenes con `alt` (`[DIR]`, `[TXT]`…), para que los navegadores de texto muestren + algo legible. +- **Perfil `text`:** el listado es una lista de líneas en vez de una tabla. +- **Fuente sin serifa:** + - Modo moderno: CSS `font-family: Verdana, "DejaVu Sans", Tahoma, Arial, Helvetica, sans-serif`. + - Modo clásico: ``, que entienden desde Netscape 3 e IE3; + los navegadores anteriores la ignoran y usan su fuente por defecto. diff --git a/DOC/COMPILACION-Y-PRUEBAS.md b/DOC/COMPILACION-Y-PRUEBAS.md index b4978e5..1276b51 100644 --- a/DOC/COMPILACION-Y-PRUEBAS.md +++ b/DOC/COMPILACION-Y-PRUEBAS.md @@ -2,9 +2,75 @@ ## Estado actual -- **Sunfish 3 en diseño:** solo hay documentación (`DOC/`, `AGENTS.md`, `README.md`). -- El código de la 2.0 sigue en el repositorio y **no** es la base de la 3: se reescribe desde - cero ([LEGACY-2.0.md](LEGACY-2.0.md)). +- **Fase 1 (hecha):** `Sunfish.Core` con configuración INI (conserva comentarios), shares y + locations, validación de rutas, motor HTTP propio (0.9/1.0/1.1, keep-alive, rangos, 304) y + web de solo lectura (índice, `?$list`, navegador de directorios clásico/moderno/texto, perfiles + de cliente, recursos GIF, autodescarga). `sunfish-cli` con `serve` y modo rápido, sin TUI. + Probado en un navegador moderno y con peticiones de cliente antiguo. +- **Fase 2 (hecha):** escritura web. Subida por formulario multipart (lector en flujo, campos + `a`/`t` antes del fichero) y `PUT` por trozos (`Content-Range` + `X-Sunfish-Upload`), crear + carpeta, renombrar y borrar con página de confirmación, *tokens* anti-CSRF sin cookies, + usuarios con HTTP Basic (PBKDF2, caché y bloqueo por intentos), ZIP en flujo (Stored, CRC + previo, `Content-Length`; nombres CP437 para clientes antiguos y UTF-8 para modernos). + `sunfish-cli user add|passwd|del|list`. `dotnet test`: 140 pruebas en verde. AOT win-x64: + ~3,8 MB sin avisos. Probado en un navegador moderno (arrastrar y soltar, borrado con + confirmación), con usuario y contraseña, y ZIP descargado como cliente antiguo. +- **Fase 3 (hecha):** aplicaciones. + - `Sunfish.exe` (WinForms): interruptor y puerto, lista de shares con activar/desactivar, + alta (también arrastrando carpetas a la ventana o sobre el `.exe`), edición, borrado, menú + con abrir en el navegador y copiar URL por IP, usuarios, registro, área de notificación e + instancia única (una segunda instancia pasa sus carpetas a la primera por una tubería). + - TUI de `sunfish-cli` (pestañas Estado con icono, Shares, Usuarios y Registro; formularios + y preguntas en recuadros) y subcomandos `share add|set|del|list`. + - `SunfishHost`: reinicio al cambiar de puerto, guardado y recarga del fichero si lo cambia + otro programa (conservando las opciones de línea de comandos como `-p`). + - Scripts `build.ps1`, `build.sh`, `run.cmd`, `run-cli.cmd` y `run.sh`. + - `dotnet test`: 149 pruebas en verde. + - **Tamaños medidos (win-x64):** `Sunfish.exe` autocontenido ~49 MB, ligero ~0,5 MB; + `sunfish-cli.exe` AOT ~3,9 MB. + - Probado: GUI en modo rápido, paso de carpetas desde una segunda instancia y alta de share + (automatizado con UI Automation), TUI dibujada en Windows Terminal, subcomandos `share` y + recarga en caliente con `serve`. `build.ps1` y `build.sh` se han comprobado solo en sintaxis + (ejecutarlos hace `git pull`); las publicaciones que lanzan sí se han ejecutado a mano. + - Después: TUI con marco Unicode y GUI con el aspecto de la 2.0 (ver + [APLICACIONES.md](APLICACIONES.md)). +- **Fase 4 (hecha):** servidor FTP ([FTP-Y-WEBDAV.md](FTP-Y-WEBDAV.md)): pasivo y activo, + anónimo y usuarios, `LIST`/`NLST`/`MLSD`/`MLST`, `RETR`/`STOR`/`APPE`/`REST`, carpetas, + renombrar y mover, borrar, `SIZE`/`MDTM`, `TYPE A`, juegos de caracteres antiguos, perfiles por + sufijo de usuario o `SITE PROFILE`, `ABOR`. El host arranca y para HTTP y FTP por separado y + comparte con la web el bloqueo por intentos fallidos. GUI (fila «FTP en puerto»), TUI (teclas + F y T) y `serve` muestran las direcciones `ftp://`. `dotnet test`: 173 pruebas en verde. + AOT win-x64: ~4,1 MB. Probado con `curl` y con `ftp.exe` de Windows en modo activo. +- **Fase 5 (hecha):** WebDAV en el servidor (clase 1 + LOCK simulado), cliente WebDAV en el + núcleo, DNS-SD sobre mDNS (anuncio y búsqueda), ventana «Explorar otro servidor» en la GUI y + pestaña Explorar (F4) en la TUI ([FTP-Y-WEBDAV.md](FTP-Y-WEBDAV.md)). `dotnet test`: 189 pruebas + en verde (incluida una búsqueda mDNS real que se salta si el 5353 no está disponible). AOT + win-x64: ~8,3 MB. Probado con el redirector WebDAV de Windows (leer y escribir) y con la ventana + Explorar de la GUI contra el propio servidor. +- La prueba de enlaces simbólicos se salta sola en Windows sin permiso para crearlos. +- Sin probar con navegadores ni clientes FTP antiguos reales, ni en Linux/macOS (GVfs, Finder, + Avahi, Bonjour); las teclas de la TUI (incluida la pestaña Explorar) no se han probado de forma + automática. +- **Fase 6 (hecha):** Mac clásico y nombres cortos ([MAC-CLASICO.md](MAC-CLASICO.md), + [CLIENTES.md](CLIENTES.md)). MacBinary I/II/III, BinHex 4.0 y AppleDouble en `Sunfish.Core/Mac`; + las subidas (formulario, PUT por trozos, `STOR`) se decodifican; descarga en MacBinary + automática para el perfil `mac` y enlaces `[.bin]`/`[.hqx]` para los demás; FTP `+mac` con + `nombre.bin`. Alias 8.3 estables (`Shares/ShortNames.cs`) en listados, enlaces, descargas y FTP + `+dos`/`+win16`; las rutas se resuelven también por alias. El `._` sigue al fichero al + renombrar, mover y borrar. `dotnet test`: 210 pruebas en verde. AOT win-x64: ~8,4 MB, sin + avisos. Probado con el binario AOT por web y FTP (`curl`). +- **Fase 7 (hecha):** panel de administración web (`Web/WebAdmin.cs`, [WEB.md](WEB.md)), + `AllowFrom` con redes CIDR (`Net/AddressRules.cs`), importación de la 2.0 + (`Config/LegacyImport.cs`, [SHARES-Y-RUTAS.md](SHARES-Y-RUTAS.md)) y generador de QR propio + (`Qr/`, [APLICACIONES.md](APLICACIONES.md)). GUI: ventana de QR, diálogo del panel web e + importación al crear la configuración. CLI: `admin`, `import-v2` y tecla C (QR) en la TUI. + `dotnet test`: 252 pruebas en verde; el QR se comprueba leyéndolo con ZXing.Net (dependencia + solo del proyecto de pruebas). AOT win-x64: ~8,7 MB, sin avisos. Probado con el binario AOT: + `import-v2`, `admin enable/allow/status`, el panel con `curl` (401/200, QR, guardar que + reinicia FTP sin cortar HTTP) y en un navegador. +- **Siguiente (sin fase asignada):** conexiones activas, iniciar con Windows, servicio de + Windows, regla del cortafuegos; pruebas con clientes antiguos reales. +- El código de la 2.0 se retiró; queda en la etiqueta `2.0` ([LEGACY-2.0.md](LEGACY-2.0.md)). - Rama actual `master` en un remoto privado. El README ya da las instrucciones para GitHub (rama `main`), para cuando se publique allí. @@ -75,15 +141,26 @@ | ZIP | Lo generado se lee con `System.IO.Compression`; Stored y Deflate; nombres CP437/UTF-8 | | Config | Lectura, escritura conservando comentarios, errores con línea, importación de `sunfish2` | | QR | Matrices frente a vectores de referencia | +| Clientes | Perfil por User-Agent, `?p=` propagado en los enlaces, sufijos `+dos`/`+mac` y `SITE PROFILE` en FTP | +| Alias 8.3 | Estabilidad al añadir y quitar ficheros, colisiones, transliteración, resolución alias → nombre real | +| `?$list` | Listado con índice presente, sin `Listing`, y webs con sus propios parámetros | +| DNS-SD | Formato de los anuncios y respuestas mDNS | +| Cliente WebDAV | Contra el propio servidor: listar, copiar en los dos sentidos, renombrar | ## Pruebas manuales previstas - **Navegadores modernos:** web clásica y moderna, arrastrar y soltar, subida grande por trozos. +- **DOS** con *packet driver*: navegador de texto y gráfico, cliente FTP y descargador HTTP de + línea de comandos; comprobar alias 8.3 y CP437. +- **Windows 3.1 / WfW 3.11** con pila Winsock: navegadores de 16 bits (listado, descargas con + alias 8.3, subida por formulario donde se admita) y cliente FTP de 16 bits. - **Windows 95/98** con sus navegadores de la época: listado, descargas, subidas por formulario, ZIP; `ftp.exe` en modo activo; Carpetas web (WebDAV). - **Mac clásico (System 6/7, Mac OS 8/9)** con navegadores y clientes FTP de la época: listado, descargas `.bin`/`.hqx`, subidas MacBinary/BinHex, nombres con acentos. - **WebDAV moderno:** Explorador de Windows, Finder de macOS, GVfs. - **GUI:** doble clic, arrastrar carpeta a la lista y sobre el `.exe`, bandeja, instancia única. -- **TUI** en Windows Terminal, conhost, Linux, macOS y por SSH. +- **TUI** en Windows Terminal, conhost, Linux, macOS y por SSH; icono visible u oculto según el + tamaño, y en 16, 256 y color real. +- **Sunfish a Sunfish:** descubrimiento en la LAN y copia en los dos sentidos. - `build.ps1` con `| iex`; `build.sh` en Linux y macOS. diff --git a/DOC/FTP-Y-WEBDAV.md b/DOC/FTP-Y-WEBDAV.md index 9a24739..a4f787b 100644 --- a/DOC/FTP-Y-WEBDAV.md +++ b/DOC/FTP-Y-WEBDAV.md @@ -1,12 +1,34 @@ # FTP y WebDAV -> Diseño previsto; aún no implementado. Ambos sirven **el mismo árbol de shares** que la web, -> con las mismas *locations*, permisos y usuarios ([SHARES-Y-RUTAS.md](SHARES-Y-RUTAS.md)). +> Ambos sirven **el mismo árbol de shares** que la web, con las mismas *locations*, permisos y +> usuarios ([SHARES-Y-RUTAS.md](SHARES-Y-RUTAS.md)). **FTP: hecho (fase 4). WebDAV, cliente +> WebDAV y DNS-SD: hechos (fase 5).** ## FTP Para los clientes con los que la web se queda corta: en Mac clásico los clientes FTP gráficos -eran la forma habitual de mover ficheros, y Windows 95 trae `ftp.exe`. +eran la forma habitual de mover ficheros, en DOS y Windows 3.1 hay clientes FTP para las pilas +TCP/IP de la época, y Windows 95 trae `ftp.exe`. + +**Implementación** (`src/Sunfish.Core/Ftp/`): `FtpServer` (escucha, límite por IP), +`FtpSession` (un hilo de control por conexión; atiende `ABOR` mientras transfiere), `DataChannel` +(pasivo/activo), `FtpPath` y `FtpListing`. Detalles que se han decidido al implementar: + +- **Anónimo siempre permitido** (`anonymous` o `ftp`, cualquier contraseña), pero solo ve los + shares sin `Users`. Un share con `Ftp = false` no aparece en FTP. +- **Opciones en `[Server]`:** `Ftp` (interruptor), `FtpPort` (2121), `FtpPassivePorts` + (`50000-50050`; vacío o inválido = cualquier puerto libre), `FtpPublicAddress` (IP anunciada + en `PASV` tras NAT) y `FtpCharset` (`iso-8859-1` por defecto; también `macroman`, `cp437`, + `cp850`, `windows-1252`, `utf-8`). +- **Juego de caracteres:** lo que llega se lee como UTF-8 si es válido y, si no, con el juego de + la sesión. `LIST`/`NLST` y las respuestas usan UTF-8 tras `OPTS UTF8 ON` y si no, el juego de la + sesión; `MLSD` siempre UTF-8 (RFC 3659). +- **`STOR` normal** pasa por un temporal y renombrado (como la web); **`APPE` y `REST`+`STOR`** + escriben sobre el fichero existente para reanudar, y por eso exigen `Overwrite`. +- **`RMD`** solo borra carpetas vacías (el borrado recursivo queda para la web). La raíz de un + share y las carpetas virtuales no se pueden borrar, renombrar ni usar para crear. +- **`TYPE A`** convierte LF en CRLF al enviar; al recibir se guarda tal cual. +- Probado con `curl` (pasivo, `EPSV`) y con `ftp.exe` de Windows (activo, `PORT`). ### Conexión @@ -47,19 +69,33 @@ ### Juego de caracteres de los nombres - Si el cliente activa `OPTS UTF8 ON` (o usa `MLSD`), UTF-8. +- Si la sesión tiene perfil (ver abajo): `dos` → CP437 (o CP850), `win16` → Windows-1252, + `mac` → MacRoman. - Si no, el juego configurado en `FtpCharset`: `iso-8859-1` (por defecto), `macroman`, `cp437`, - `cp850` o `windows-1252`. **Pendiente:** elegirlo por cliente; por ahora es global. + `cp850` o `windows-1252`. -### Mac clásico por FTP +### Nombres cortos y Mac en FTP -Con `Mac = true` en el share, un `STOR` de un fichero `.bin` con cabecera MacBinary II/III válida -(CRC correcto) se decodifica igual que en la web. Ver [MAC-CLASICO.md](MAC-CLASICO.md). -**Pendiente:** ofrecer al listar entradas virtuales `.bin` para descargar con *resource fork*. +FTP no dice qué cliente se conecta, así que los perfiles de [CLIENTES.md](CLIENTES.md) se eligen +por sesión: + +- **Sufijo en el usuario:** `anonymous+dos`, `ana+dos`, `anonymous+mac`… (`+dos` y `+win16` + activan alias 8.3; `+mac` activa la entrega en MacBinary). Funciona con cualquier cliente, + porque basta con escribirlo al conectar. +- **Comando `SITE`:** `SITE PROFILE dos|win16|mac|none`, para clientes que permiten enviar + comandos. +- **Por defecto (sin perfil):** nombres reales y ficheros tal cual. +- **Subidas:** un `STOR` con MacBinary II/III o BinHex válidos se decodifica siempre, como en la + web (salvo `Mac = false`). Ver [MAC-CLASICO.md](MAC-CLASICO.md). +- **Perfil `mac`:** los ficheros con `._` se listan como `nombre.bin` y se descargan en MacBinary. + Los clientes FTP de Mac clásico suelen decodificarlo solos. Los nombres de más de 31 caracteres + no se acortan en FTP (sí en las descargas web). ## WebDAV -Sobre el mismo puerto HTTP, activable por share (`WebDav = true`), para montar los shares como -una carpeta desde sistemas modernos (y algunos antiguos). +Sobre el mismo puerto HTTP, activo por defecto en todos los shares (`WebDav = false` lo +desactiva), para montar los shares como una carpeta desde sistemas modernos (y algunos +antiguos). Es también el protocolo con el que un Sunfish explora a otro (ver más abajo). ### Alcance @@ -76,12 +112,60 @@ - **XML:** se lee con `XmlReader` con `DtdProcessing.Prohibit` (sin entidades externas). - **Permisos:** los mismos del share; `Listing` controla `PROPFIND` de profundidad 1. +**Implementación** (`src/Sunfish.Core/Web/WebDav.cs`, dentro de `WebApp`; `GET`, `HEAD` y `PUT` +son los de la web). Decisiones tomadas al implementar: + +- `OPTIONS` responde siempre (sin usuario) con `DAV` y `MS-Author-Via`; el resto exige el + usuario del share como la web. Un share con `WebDav = false` responde 405 a los métodos DAV. +- La raíz y las carpetas intermedias de *locations* son colecciones virtuales de solo lectura. +- `PROPFIND` sin cabecera `Depth` se trata como profundidad 1 (lo esperan varios clientes). +- **`PUT` puede sustituir un fichero vacío aunque el share no tenga `Overwrite`:** el Explorador + y Finder crean primero un fichero vacío (`PUT` o `LOCK` de un recurso inexistente) y después + lo rellenan. +- **`._nombre` y `.DS_Store`** (metadatos que escribe Finder) se aceptan aunque empiecen por `.`; + no salen en los listados. Al borrar un fichero se borra también su `._`. +- `COPY`/`MOVE` solo dentro del mismo share (si no, 502); sustituir el destino exige `Delete`; + `Overwrite: F` responde 412. `COPY` de carpetas es recursivo y no sigue enlaces. +- `PROPPATCH` responde 200 a todo sin aplicar nada (el Explorador fija sus atributos Win32). +- **Probado con el redirector WebDAV de Windows** (`\\equipo@puerto\DavWWWRoot`): listar, + leer, copiar un fichero (PUT vacío, LOCK, PUT, PROPPATCH, UNLOCK), crear carpeta y renombrar. + ### Clientes y limitaciones conocidas | Cliente | Notas | |---|---| | Explorador de Windows 10/11 (WebClient) | Por defecto **no envía Basic sin HTTPS**: solo funcionan los shares públicos, salvo cambiando `BasicAuthLevel` en el registro. Límite de fichero de ~50 MB por defecto (`FileSizeLimitInBytes`) | -| Finder de macOS | Necesita `LOCK`; crea ficheros `._*` y `.DS_Store` (con `Mac = true` se tratan como AppleDouble) | -| GVfs / gestores de Linux | `dav://host:puerto/location` | +| Finder de macOS | Necesita `LOCK`; crea ficheros `._*` y `.DS_Store`, que encajan con el almacenamiento AppleDouble | +| GVfs / gestores de Linux | `dav://host:puerto/location`; los servidores anunciados por DNS-SD aparecen en "Red" | | Carpetas web de Windows 98/2000 | A probar | | Clientes WebDAV de Mac OS 9 | A probar | +| Otro Sunfish | Explorador integrado en las aplicaciones (ver abajo) | + +## De Sunfish a Sunfish + +No se añade un protocolo propio: un Sunfish explora a otro **por WebDAV**, igual que lo haría +un gestor de ficheros. Así funciona también con cualquier otro servidor WebDAV. + +- **Cliente WebDAV en `Sunfish.Core`:** `PROPFIND`, `GET`, `PUT`, `MKCOL`, `MOVE` y `DELETE` con + `HttpClient`. Las aplicaciones lo usan para un panel "Explorar otro servidor" con copia en los + dos sentidos (ver [APLICACIONES.md](APLICACIONES.md)). +- **Descubrimiento en la LAN con DNS-SD sobre mDNS:** cada Sunfish se anuncia como + `_http._tcp`, `_webdav._tcp` y `_ftp._tcp`, con un registro TXT `sunfish=3` y `path=/`. + - Las aplicaciones Sunfish listan los servidores de la red sin escribir IPs. + - Los gestores de ficheros que exploran DNS-SD (GVfs, por ejemplo) los ven solos. + - El Explorador de Windows no explora DNS-SD: ahí hay que escribir la dirección. +- **mDNS propio** (UDP 5353 multicast, con `ReuseAddress` para convivir con Avahi, Bonjour o el + mDNS de Windows). Se puede desactivar con `Announce = false` en `[Server]`. + +**Implementación:** `Dav/DavClient.cs` (cliente), `Net/DnsMessage.cs` (mensajes DNS) y +`Net/Discovery.cs` (`MdnsAnnouncer` y `Discovery.BrowseAsync`). + +- El anunciante se llama ` ()` y usa el nombre de host `sunfish-.local` + (para no pisar el del sistema). Anuncia al arrancar y responde a las preguntas; si no puede + abrir el 5353, lo anota en el registro y sigue sin anunciar. +- La búsqueda pregunta desde un puerto cualquiera (los respondedores contestan a ese puerto, + "legacy unicast"), así que funciona aunque el 5353 esté ocupado. +- Probado en Windows: convive con el mDNS del sistema y un Sunfish se encuentra a sí mismo. + **Pendiente:** comprobarlo con Avahi (Linux) y Bonjour (macOS). +- `sunfish-cli` publicado con AOT pasa de ~4 MB a ~8 MB al incluir `HttpClient` y XML (cliente + WebDAV). diff --git a/DOC/MAC-CLASICO.md b/DOC/MAC-CLASICO.md index a665425..527ad9f 100644 --- a/DOC/MAC-CLASICO.md +++ b/DOC/MAC-CLASICO.md @@ -1,6 +1,7 @@ # Ficheros de Mac clásico -> Diseño previsto; aún no implementado. Se activa por share con `Mac = true`. +> Implementado (fase 6). **Funciona de forma transparente** en todos los shares; +> `Mac = false` lo desactiva en un share concreto. ## El problema @@ -16,9 +17,21 @@ - **MacBinary** (`.bin`): binario, una cabecera de 128 bytes y después los dos forks. - **BinHex 4.0** (`.hqx`): texto de 7 bits, apto incluso para correo. +## Por qué puede ser transparente + +- **Detectar MacBinary II/III y BinHex es fiable**, porque llevan CRC: un fichero que no lo es + prácticamente nunca pasa la comprobación por casualidad. +- **El fichero decodificado se puede volver a codificar sin pérdida:** el *data fork* queda como + fichero normal y el resto se guarda aparte. +- **Si el fichero no tiene nada propio de Mac, no se toca.** + +Por eso no hace falta activarlo. `Mac = false` solo tiene sentido en shares que guardan +`.bin`/`.hqx` como archivo (p. ej. una colección de software para emuladores), donde se quiere +conservar el fichero codificado tal cual. + ## Almacenamiento: AppleDouble -Sunfish guarda cada fichero de Mac como: +Cada fichero de Mac se guarda como: - `nombre`: el *data fork*, usable desde cualquier sistema; - `._nombre`: fichero **AppleDouble** (versión 2) en la misma carpeta, con la entrada 9 (Finder @@ -27,46 +40,54 @@ Es el mismo formato que usa macOS en volúmenes que no admiten forks (FAT, SMB…), así que es compatible con macOS moderno y con otras herramientas. -- Con `Mac = true`, los `._*` que acompañan a un fichero, `.DS_Store` y `Icon\r` no se muestran - en los listados (web, FTP, WebDAV). +- Los `._*` que acompañan a un fichero, `.DS_Store` e `Icon\r` no se muestran en los listados + (web, FTP, WebDAV). - Renombrar, mover o borrar un fichero hace lo mismo con su `._`. ## Subir -Por la web o por FTP, en un share con `Mac = true`: +Por formulario web, por la subida por trozos de la página moderna (`PUT` con `X-Sunfish-Upload`) o por FTP (`STOR`): -| Llega | Se detecta si… | Se guarda | +| Llega | Se decodifica si… | Se guarda | |---|---|---| | MacBinary II/III | byte 0 y 74 a cero, longitud de nombre 1–63, longitudes de los forks coherentes y **CRC-16 de la cabecera correcto** (III: firma `mBIN` en el desplazamiento 102) | `nombre` + `._nombre` | -| MacBinary I | Sin CRC: solo si la extensión es `.bin` y la cabecera es coherente | `nombre` + `._nombre` | +| MacBinary I | Sin CRC: solo si el cliente tiene perfil `mac`, el fichero acaba en `.bin` y la cabecera es coherente | `nombre` + `._nombre` | | BinHex 4.0 | Línea `(This file must be converted with BinHex 4.0)` y CRC de las tres partes correctos | `nombre` + `._nombre` | | Cualquier otra cosa | — | tal cual | -- El nombre se toma de la cabecera (MacRoman → Unicode). Si el fichero no tenía *resource fork* - ni información relevante, no se crea el `._`. -- **Pendiente:** opción para conservar además el `.bin`/`.hqx` original. +- El nombre se toma de la cabecera (MacRoman → Unicode). +- Si el fichero no tenía *resource fork* ni información relevante del Finder, no se crea el `._`. +- Si el nombre decodificado ya existe y el share no tiene `Overwrite`, **se conserva el fichero + codificado** tal como llegó (no se pierde nada) y el registro lo avisa. Con `Overwrite`, se + sustituye. +- Un `PUT` de WebDAV normal **no se decodifica**: los clientes WebDAV modernos (Finder, Windows) + ya escriben los forks como `._` por su cuenta, y un `.bin` copiado así se quiere tal cual. +- BinHex se decodifica en memoria: solo hasta 256 MB. ## Descargar -En el navegador de directorios de un share con `Mac = true`, junto a cada fichero aparecen dos -enlaces: +- **Cliente con perfil `mac`** ([CLIENTES.md](CLIENTES.md)), fichero con `._`: la descarga normal + se entrega **en MacBinary automáticamente** (`application/x-macbinary`). El decodificador del + navegador (o el que tenga asociado) restaura los forks sin que el usuario haga nada. +- **Cliente con perfil `mac`, fichero sin `._`:** se entrega tal cual. El Mac asigna tipo y + creador por su tabla de extensiones. +- **Cualquier otro cliente:** recibe el *data fork*. Si el fichero tiene `._`, el listado lo + marca y ofrece enlaces explícitos: + - `[.bin]` → `fichero?as=macbin`, MacBinary III (también lo leen los decodificadores de + MacBinary II); + - `[.hqx]` → `fichero?as=hqx`, `application/mac-binhex40`. -- `[.bin]` → `fichero?as=macbin`, `application/x-macbinary`, MacBinary III (con CRC, lo leen - también los decodificadores de MacBinary II); -- `[.hqx]` → `fichero?as=hqx`, `application/mac-binhex40`. +Al codificar: -El servidor construye el fichero al vuelo: - -- *data fork* = el fichero; -- *resource fork* e información del Finder = su `._` si existe; -- si no existe `._`, tipo y creador salen de una **tabla por extensión** (p. ej. `.txt` → - `TEXT`, `.gif` → `GIFf`, `.jpg` → `JPEG`, `.sit` → `SIT!`, `.pdf` → `PDF `), ampliable en la - configuración con una sección `[MacTypes]`; +- *data fork* = el fichero; *resource fork* e información del Finder = su `._`; +- si no hay `._` (enlaces explícitos), tipo y creador salen de una **tabla por extensión** (p. ej. + `.txt` → `TEXT`, `.gif` → `GIFf`, `.jpg` → `JPEG`, `.sit` → `SIT!`, `.pdf` → `PDF `). **Pendiente:** + ampliarla + con una sección `[MacTypes]` en la configuración (aún no existe); - el nombre se convierte a MacRoman y se acorta a 31 caracteres (límite de HFS), conservando la extensión. -Por FTP, los clientes de Mac suelen saber codificar y decodificar MacBinary por su cuenta; el -servidor solo decodifica lo que se sube (ver [FTP-Y-WEBDAV.md](FTP-Y-WEBDAV.md#mac-clásico-por-ftp)). +Para FTP, ver [FTP-Y-WEBDAV.md](FTP-Y-WEBDAV.md#nombres-cortos-y-mac-en-ftp). ## Fuera de alcance @@ -74,10 +95,18 @@ - Conversión de saltos de línea `CR` de los ficheros de texto de Mac (lo hace el cliente o el modo `TYPE A` de FTP). -## Pruebas previstas +## Pruebas -- MacBinary I, II y III: decodificar y codificar de ida y vuelta, CRC, falsos positivos (ficheros - `.bin` que no son MacBinary). -- BinHex: ida y vuelta, RLE (`0x90`), líneas de 64 caracteres, CRC. -- AppleDouble: lectura y escritura, compatibilidad con los `._` que genera macOS. -- Tabla de tipos y nombres MacRoman de 31 caracteres. +En `tests/Sunfish.Tests/MacTests.cs`: + +- MacBinary III de ida y vuelta (forks, tipo, creador, fecha), CRC alterado y falsos positivos + (datos aleatorios, un `.bin` cualquiera queda intacto). +- BinHex de ida y vuelta con bytes `0x90` (RLE) y CRC alterado. +- AppleDouble de ida y vuelta; el `._` sigue al fichero al mover y borrar. +- Decodificación al subir y conservación del codificado si el nombre ya existe. +- Servidor real: enlaces `[.bin]`/`[.hqx]`, `?as=macbin`/`?as=hqx`, entrega automática al perfil + `mac`, subida por formulario que se decodifica, FTP `+mac` (`nombre.bin` en el listado, `SIZE`, + `RETR`, `STOR` que se decodifica). +- Probado también con el binario AOT: subida MacBinary por FTP, separación en `nombre` + `._nombre` + y descarga `.bin` idéntica byte a byte. +- **Sin probar** con un Mac clásico real ni con `._` generados por macOS moderno. diff --git a/DOC/README.md b/DOC/README.md index 15205ea..7686172 100644 --- a/DOC/README.md +++ b/DOC/README.md @@ -8,8 +8,8 @@ Servidor web y de ficheros ligero y portable, en C# (.NET 10). Publica carpetas del disco a la vez por **web** (con un navegador de directorios en HTML4 que funciona en navegadores de los -90), **FTP** y, opcionalmente, **WebDAV**. Su uso principal es mover ficheros entre equipos -modernos y **máquinas muy antiguas** (Windows 95, Mac clásico) en una red de confianza. La +90), **FTP** y **WebDAV**. Su uso principal es mover ficheros entre equipos modernos y +**máquinas muy antiguas** (DOS, Windows 3.1 y 9x, Mac clásico) en una red de confianza. La versión 3 es una reescritura completa de la 2.0. ## Índice @@ -20,7 +20,8 @@ | [SHARES-Y-RUTAS.md](SHARES-Y-RUTAS.md) | El share, *locations* por prefijo más largo, validación de rutas, permisos, configuración, importación de la 2.0 | | [WEB.md](WEB.md) | Motor HTTP propio, interfaz clásica/moderna, juegos de caracteres, subidas, ZIP, panel de administración | | [FTP-Y-WEBDAV.md](FTP-Y-WEBDAV.md) | Servidor FTP (activo/pasivo, comandos, juegos de caracteres) y WebDAV | -| [MAC-CLASICO.md](MAC-CLASICO.md) | MacBinary, BinHex y AppleDouble para no perder *resource forks* | +| [CLIENTES.md](CLIENTES.md) | Plataformas de destino, perfiles de cliente automáticos, alias 8.3, fuente | +| [MAC-CLASICO.md](MAC-CLASICO.md) | MacBinary, BinHex y AppleDouble transparentes para no perder *resource forks* | | [SEGURIDAD.md](SEGURIDAD.md) | Alcance (en claro, redes de confianza) y medidas de protección del servidor | | [APLICACIONES.md](APLICACIONES.md) | `Sunfish.exe` (WinForms), `sunfish-cli` (TUI/consola), futuras GTK y macOS | | [COMPILACION-Y-PRUEBAS.md](COMPILACION-Y-PRUEBAS.md) | Estado, publicación, requisitos, scripts y pruebas previstas | @@ -36,7 +37,11 @@ estricto con clientes antiguos y no permite AOT). - **Web, FTP y WebDAV** sirven el mismo árbol de shares; gana la *location* más específica. - **Modo clásico** de la web: HTML 3.2/4.01, sin JavaScript, ISO-8859-1 con entidades, iconos - GIF. + GIF, fuente sin serifa. +- **Adaptación automática al cliente** (perfiles `dos`, `win16`, `mac`, `text`, `classic`, + `modern`): alias 8.3, MacBinary, juegos de caracteres. Las opciones del share solo sirven para + desactivarla (`Mac = false`, `ShortNames = false`). +- **Sunfish a Sunfish por WebDAV** + DNS-SD; no hay protocolo propio. - **En claro y sin TLS**, a propósito; nunca se requiere administrador; nunca se ejecuta nada en el servidor. - **Textos de usuario** en español e inglés; claves del fichero de configuración en inglés. @@ -45,11 +50,12 @@ Recogidas con la marca **pendiente** en cada documento. Las principales: -- Variante por defecto de `Sunfish.exe` (autocontenida o dependiente del framework): medir - tamaños. -- `build.ps1` sin herramientas de C++: ¿fallar o compilar solo la GUI? - Permisos por usuario dentro de un share. -- Juego de caracteres de FTP y de formularios por cliente (en vez de global). -- Entradas virtuales `.bin` en FTP; conservar los `.bin`/`.hqx` originales al subir. +- GUI y panel web: iniciar con Windows y lista de conexiones activas (diseñadas, no hechas). +- Juego de caracteres de los formularios enviados por clientes antiguos (probar con clientes + reales). +- Firmas de User-Agent de cada perfil (se completan al probar). +- Sección `[MacTypes]` para ampliar la tabla de tipos y creadores de Mac. +- Convivencia del mDNS propio con el del sistema. - Regla del cortafuegos de Windows desde la GUI; servicio de Windows para `sunfish-cli`. - Bindings para las interfaces GTK y macOS. diff --git a/DOC/SEGURIDAD.md b/DOC/SEGURIDAD.md index a596213..04074b9 100644 --- a/DOC/SEGURIDAD.md +++ b/DOC/SEGURIDAD.md @@ -56,7 +56,10 @@ - **Desactivado por defecto.** Necesita contraseña y una IP incluida en `AllowFrom` (por defecto, solo el propio equipo). -- **Cambiar la contraseña** desde el panel exige la actual. +- **Cambiar la contraseña o `AllowFrom`** desde el panel exige la actual. +- **No se puede dejar fuera a quien lo cambia:** una lista `AllowFrom` que no incluya la + dirección desde la que se está conectado se rechaza. +- El panel no cambia el HTTP (puerto, interfaces): eso queda para quien tiene la aplicación. ### Red y recursos diff --git a/DOC/SHARES-Y-RUTAS.md b/DOC/SHARES-Y-RUTAS.md index 00d09f1..80acc52 100644 --- a/DOC/SHARES-Y-RUTAS.md +++ b/DOC/SHARES-Y-RUTAS.md @@ -6,10 +6,13 @@ En la 2.0 se distinguía "servir una web" y "compartir una carpeta". En la 3 son lo mismo: un **share** publica una carpeta del disco en una *location* (ruta URL) y se ofrece **a la vez** -por web, FTP y, si se activa, WebDAV. +por web, FTP y WebDAV. - Si la carpeta pedida tiene documento índice (`index.html` por defecto), la web lo sirve. - Si no lo tiene y el listado está permitido, se muestra el navegador de directorios. +- **`?$list`** fuerza el navegador de directorios aunque haya índice (p. ej. + `/web/?$list`), si el share tiene `Listing`. Así se puede ver y descargar el contenido de una + carpeta que publica una web. El `$` evita chocar con los parámetros que use la propia web. - Una URL directa a un fichero lo descarga (o el navegador lo muestra, según su tipo MIME). ## Locations: gana la más específica @@ -60,6 +63,10 @@ administración (`/$sunfish/admin/`) y la autodescarga del ejecutable (`/$sunfish/download`). Ninguna *location* puede empezar por `/$sunfish`. +Los parámetros propios de Sunfish en la URL empiezan por `$` (`?$list`) o son de una letra +reconocida solo en páginas generadas (`?p=`, `?ui=`, `?sort=`, `?as=`). Una web servida desde +un share recibe sus propios parámetros sin interferencias. + ## Validación de rutas Toda ruta de web, FTP o WebDAV pasa por la misma validación antes de tocar el disco: @@ -87,6 +94,13 @@ | `Delete` | Borrar ficheros y carpetas | | `Zip` | Descargar una carpeta como ZIP | +Adaptaciones automáticas, activadas por defecto (solo se configuran para desactivarlas): + +| Opción | Efecto | Documento | +|---|---|---| +| `Mac` | MacBinary/BinHex transparentes y forks en AppleDouble | [MAC-CLASICO.md](MAC-CLASICO.md) | +| `ShortNames` | Alias 8.3 para DOS y Windows 3.1 | [CLIENTES.md](CLIENTES.md) | + **Usuarios:** si un share tiene `Users`, todo acceso (web, FTP, WebDAV) requiere usuario y contraseña de esa lista; si no, es público. **Pendiente:** permisos distintos por usuario dentro de un mismo share (p. ej. lectura pública y escritura con usuario). @@ -128,13 +142,17 @@ Upload = true Zip = true Users = ana -Mac = true -WebDav = true -Enabled = true + +[Share juegos-mac] +Location = /archivo +Path = D:\Archivo +# Coleccion de .bin/.hqx que deben quedarse codificados +Mac = false ``` - Valores por defecto: `Index = index.html` (vacío = sin índice), `Listing = true`, - `Ftp = true`, `WebDav = false`, `Mac = false`, `Enabled = true`, el resto de permisos `false`. + `Ftp = true`, `WebDav = true`, `Mac = true`, `ShortNames = true`, `Enabled = true`, el resto + de permisos `false`. - La GUI, la TUI, los subcomandos y el panel web reescriben el fichero **conservando comentarios y orden**, con escritura atómica (temporal + renombrado). - Un cambio en el fichero hecho por otro proceso se detecta y se recarga; si no es válido se @@ -156,8 +174,20 @@ ### Importar la configuración de la 2.0 -La 2.0 guardaba un JSON llamado `sunfish2` en el directorio de trabajo. Si no hay configuración -nueva y existe `sunfish2` junto al ejecutable, se ofrece importarlo: +La 2.0 guardaba un JSON llamado `sunfish2` en el directorio de trabajo. Implementado en +`Config/LegacyImport.cs` (fase 7, con `JsonDocument`, compatible con AOT): + +- **Al crear la configuración** (GUI y TUI), si existe `sunfish2` junto al ejecutable o en la + carpeta actual, se ofrece importarlo. +- **En cualquier momento:** `sunfish-cli import-v2 [FICHERO]` añade sus shares a la + configuración existente (o la crea). +- Los nombres de las claves se leen sin distinguir mayúsculas; `Settings` se acepta como objeto o + como lista de pares `Key`/`Value`. +- Si un nombre o una *location* ya existen, se usa uno libre (`fotos-2`). Los nombres con + espacios se juntan (en el INI no valen). +- El puerto y el menú raíz solo se copian si la configuración es nueva. Un puerto por debajo de + 1024 (la 2.0 usaba 90) se cambia por 8080, porque no se pide administrador. +- Al terminar se listan las notas: lo ignorado y las carpetas que no existen. | 2.0 | 3 | |---|---| diff --git a/DOC/WEB.md b/DOC/WEB.md index 6ba706d..ca60448 100644 --- a/DOC/WEB.md +++ b/DOC/WEB.md @@ -47,8 +47,12 @@ | **Clásico** | Navegadores antiguos (por defecto si el User-Agent no es moderno) | HTML 3.2/4.01 Transitional, maquetado con tablas, sin JavaScript, ISO-8859-1 | | **Moderno** | Navegadores actuales | La misma estructura, UTF-8, un CSS sencillo y un JS pequeño para arrastrar y soltar y para subir por trozos con progreso | -- El servidor decide por el User-Agent. El usuario puede forzar el modo con un enlace - (`?ui=classic` / `?ui=modern`), que se recuerda en una cookie si el navegador las admite. +- El servidor decide por el User-Agent, dentro de los **perfiles de cliente** (`dos`, `win16`, + `mac`, `text`, `classic`, `modern`) descritos en [CLIENTES.md](CLIENTES.md), que además ajustan + nombres (8.3, 31 caracteres), juego de caracteres y entrega de ficheros de Mac. +- **Forzar el perfil:** `?p=…`. El perfil forzado se propaga en los enlaces de la página + (algunos clientes antiguos no guardan cookies) y se recuerda en una cookie cuando se puede. +- **Fuente sin serifa** en los dos modos (CSS en el moderno, `` en el clásico). - El modo clásico **no incluye ningún `"; - WebUI.WriteTemplate("directory-index", call, data); - } - - private WebUILink[] GetBreadcrumb(string path) - { - string link = ""; - path = Configuration.Location + path; - List lst = new List(); - lst.Add(RootService.LinkHome); - foreach (string p in path.Split('/')) - { - if (p.Length == 0) - continue; - link += "/" + p; - lst.Add(new WebUILink() - { - Name = p, - Link = link, - }); - } - return lst.ToArray(); - } - - #endregion - - protected override void Start() - { - } - - protected override void Stop() - { - } - - public override string Description => "For Webpages or file sharing"; - } -} diff --git a/Sunfish/Services/WebServiceConfigurator.cs b/Sunfish/Services/WebServiceConfigurator.cs deleted file mode 100644 index 32f4118..0000000 --- a/Sunfish/Services/WebServiceConfigurator.cs +++ /dev/null @@ -1,63 +0,0 @@ -using Sunfish.Configurator; -using System; - -namespace Sunfish.Services -{ - class WebServiceConfigurator : SunfishServiceConfigurator - { - internal const string CFG_PATH = "path"; - internal const string CFG_INDEX = "defaultFile"; - internal const string CFG_SHARE = "folderShare"; - internal const string CFG_RONLY = "readOnly"; - internal const string CFG_DELETE = "allowDeletion"; - internal const string CFG_EXECUTE = "allowServerExecution"; - - private void AdvancedEditing(SunfishServiceConfiguration ssc) - { - - } - - internal protected override ConfigurationScreen GetConfigurationScreen() - { - return new ConfigurationScreen() - { - Elements = new ConfigurationElement[] - { - new ConfigurationString(CFG_PATH,"Directory") - { - Tooltip = "Folder of site document root", - IsDirectoiryPath = true, - IsFilePath = true, - Mandatory = true - }, - new ConfigurationString(CFG_INDEX,"Default document") - { - Tooltip = "Default document for directory path", - DefaultValue = "index.html" - }, - new ConfigurationBool(CFG_SHARE,"Folder share")// (Web && DAV)") - { - Tooltip = "Allow directory listings, switched off the server will report a forbidden message if the directory has no default file. Also enables DAV protocol", - DefaultValue = true, - }, - new ConfigurationBool(CFG_RONLY,"Read only") - { - Tooltip = "Avoids upload of files and folders, create folders, edit files, copy and move.", - DefaultValue = true, - }, - new ConfigurationBool(CFG_DELETE,"Allow delete") - { - Tooltip = "Allow delete files and folders." - }, - new ConfigurationBool(CFG_EXECUTE,"Allow execute") - { - Tooltip = "Allow application execution on server." - }, - new ConfigurationMessage(ConfigurationMessage.MessageType.WARNING,"WARNING: Sunfish is executed as elevated process, any action or process started by sunfish will be also elevated.") - }, - //Advanced = AdvancedEditing - }; - } - - } -} diff --git a/Sunfish/Sunfish.cs b/Sunfish/Sunfish.cs deleted file mode 100644 index 3fa62d8..0000000 --- a/Sunfish/Sunfish.cs +++ /dev/null @@ -1,185 +0,0 @@ -using Sunfish.Middleware; -using Sunfish.Services; -using Json.Net; -using System; -using System.Collections.Generic; -using System.IO; -using System.Net; - -namespace Sunfish -{ - static class Sunfish - { - class SunfishConfiguration - { - public int Port { get; set; } = 90; - public bool Active { get; set; } = false; - public bool SunfishRoot { get; set; } = true; - public bool AdminPanel { get; set; } = false; - public string AdminPwd { get; set; } = ""; - public List Services { get; set; } = new List(); - } - - public const string DEFAULT_CONF_FILE = "sunfish2"; - private static SunfishConfiguration conf = new SunfishConfiguration(); - private static List srvs = new List(); - private static RootService sroot = new RootService(); - private static HttpServer server; - - #region Configuration & Initialization - static Sunfish() - { - Load(); - } - - static void Load() - { - try - { - if (!File.Exists(DEFAULT_CONF_FILE)) - return; - conf = new SunfishConfiguration(); - conf = JsonNet.Deserialize(File.ReadAllText(DEFAULT_CONF_FILE)); - if (conf.Services == null) - conf.Services = new List(); - sroot.ShowMenu = conf.SunfishRoot; - foreach (SunfishServiceConfiguration ssc in conf.Services) - { - srvs.Add(SunfishService.Instance(ssc)); - } - if (conf.Active) - { - //Bypass set active check - conf.Active = false; - SetActive(true); - } - } - catch { } - } - - public static void Save() - { - string json = JsonNet.Serialize(conf); - File.WriteAllText(DEFAULT_CONF_FILE, json); - } - #endregion - - private static void SetActive(bool act) - { - if (conf.Active == act) - return; - if (act) - { - server = new HttpServer(conf.Port, server_Process); - server.Error += server_Error; - try - { - server.Start(); - } - catch (Exception e) - { - server = null; - throw e; - } - } - else - { - if (server != null) - { - server.Stop(); - server = null; - } - } - conf.Active = act; - } - - static void server_Process(HttpServer server, HttpCall call) - { - var path = call.Request.Url.LocalPath; - SunfishService s = GetServiceForPath(ref path); - if (string.IsNullOrWhiteSpace(path)) - call.Redirect(call.Request.Url.LocalPath + "/"); - else - { - if (s == null || !s.Enabled) - ErrorService.Process404(call); - else - s.Process(path, call); - } - call.Close(); - } - - static void server_Error(HttpServer server, Exception e) - { - //TODO: Log - //if (!(e is ObjectDisposedException) && !(e is HttpListenerException)) - // MessageBox.Show(e.Message + "\r\n" + e.StackTrace, e.GetType().Name, MessageBoxButtons.OK, MessageBoxIcon.Error); - } - - private static void SetPort(int port) - { - bool act = Active; - Active = false; - conf.Port = port; - Active = act; - } - - public static SunfishService AddService(SunfishServiceConfiguration ssc) - { - SunfishService s; - srvs.Add(s = SunfishService.Instance(ssc)); - conf.Services.Add(ssc); - return s; - } - - public static SunfishService ReplaceService(SunfishService sold, SunfishServiceConfiguration ssc) - { - SunfishService s = SunfishService.Instance(ssc); - int i = srvs.IndexOf(sold); - if (i < 0) - srvs.Add(s); - else - srvs[i] = s; - i = conf.Services.IndexOf(ssc); - if (i < 0) - conf.Services.Add(ssc); - return s; - } - - public static void DeleteService(SunfishService srv) - { - conf.Services.Remove(srv.Configuration); - } - - public static SunfishService GetServiceForPath(ref string path) - { - if (string.IsNullOrEmpty(path) || path == "/" || path.StartsWith("/$sunfish")) - return sroot; - string candidateRelativePath = ""; - SunfishService candidate = null; - foreach (SunfishService s in srvs) - { - string plb = s.Configuration.Location; - if (!plb.EndsWith("/")) - plb = plb + "/"; - if ((path == s.Configuration.Location || path.StartsWith(plb)) && - (candidate == null || (candidate.Configuration.Location.Length < s.Configuration.Location.Length))) - { - candidate = s; - candidateRelativePath = path.Substring(s.Configuration.Location.Length); - } - } - if (candidate != null) - path = candidateRelativePath; - return candidate; - } - - public static bool Active { get => conf.Active; set => SetActive(value); } - public static int Port { get => conf.Port; set => SetPort(value); } - public static bool RootMenu { get => conf.SunfishRoot; set => conf.SunfishRoot = value; } - public static bool AdminPanel { get => conf.AdminPanel; set => conf.AdminPanel = value; } - public static string AdminPwd { get => conf.AdminPwd; set => conf.AdminPwd = value; } - - public static SunfishService[] Services => srvs.ToArray(); - } -} diff --git a/Sunfish/Sunfish.csproj b/Sunfish/Sunfish.csproj deleted file mode 100644 index aea205d..0000000 --- a/Sunfish/Sunfish.csproj +++ /dev/null @@ -1,205 +0,0 @@ - - - - Debug - AnyCPU - 9.0.30729 - 2.0 - {5511BE4E-6AE1-479B-B5CD-5A20DC4C13F7} - WinExe - Properties - Sunfish - Sunfish - v4.5 - 512 - sunfishWebServer.ico - - - - - - - 3.5 - publish\ - true - Disk - false - Foreground - 7 - Days - false - false - true - 0 - 1.0.0.%2a - false - false - true - - - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - false - - - - pdbonly - true - bin\Release\ - TRACE - prompt - 0 - false - AnyCPU - - - - - - - app.manifest - - - - - 3.5 - - - - - - - - - - - - - - Form - - - Form1.cs - - - Form - - - FServiceConf.cs - - - - - - - - - - - - - - - - - - - - - - - - - - Form1.cs - - - FServiceConf.cs - - - ResXFileCodeGenerator - Resources.Designer.cs - Designer - - - True - Resources.resx - True - - - - - SettingsSingleFileGenerator - Settings.Designer.cs - - - True - Settings.settings - True - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - False - .NET Framework 3.5 SP1 Client Profile - false - - - False - .NET Framework 3.5 SP1 - true - - - - - - - - \ No newline at end of file diff --git a/Sunfish/SunfishService.cs b/Sunfish/SunfishService.cs deleted file mode 100644 index ac522fb..0000000 --- a/Sunfish/SunfishService.cs +++ /dev/null @@ -1,123 +0,0 @@ -using Sunfish.Configurator; -using Sunfish.Middleware; -using Sunfish.Services; -using System; -using System.Collections.Generic; -using System.IO; -using System.Reflection; - -namespace Sunfish -{ - public abstract class SunfishService : IDisposable - { - #region Static / Type management - - private static Type[] ServiceTypes; - - static SunfishService() - { - ScanForServices(); - } - - private static void ScanForServices() - { - List st = new List(); - ScanForServices(Assembly.GetExecutingAssembly(), st); - string sfpath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); - foreach (string s in Directory.GetFiles(sfpath)) try - { - var sname = Path.GetFileName(s); - if (sname.StartsWith("sf-") && sname.EndsWith(".dll")) - ScanForServices(Assembly.LoadFile(s), st); - } - catch (Exception e) - { - //TODO: LOG - } - ServiceTypes = st.ToArray(); - } - - private static void ScanForServices(Assembly a, List st) - { - Type tme = typeof(SunfishService); - foreach (Type t in a.GetTypes()) - { - if (tme.IsAssignableFrom(t) && !t.IsAbstract && t != typeof(ErrorService) && t != typeof(RootService)) - st.Add(t); - } - } - - private static Type GetServiceTypeOf(string type) - { - Type stype = null; - foreach (Type t in ServiceTypes) - if (t.Name == type) - stype = t; - if (stype == null) - stype = typeof(ErrorService); - return stype; - } - - internal static SunfishService Instance(SunfishServiceConfiguration ssc) - { - Type stype = GetServiceTypeOf(ssc.Type); - try - { - return (SunfishService)stype.GetConstructor(new Type[] { typeof(SunfishServiceConfiguration) }).Invoke(new object[] { ssc }); - } - catch { return new ErrorService(ssc); } - } - - public static SunfishServiceConfigurator GetConfigurator(string type) - { - return SunfishServiceConfigurator.GetConfiguratorForService(GetServiceTypeOf(type)); - } - - public static string[] GetTypes() - { - List lresult = new List(); - foreach (Type t in ServiceTypes) - lresult.Add(t.Name); - return lresult.ToArray(); - } - - #endregion - - private SunfishServiceConfigurator configurator; - - public SunfishService(SunfishServiceConfiguration ssc) - { - Configuration = ssc; - configurator = SunfishServiceConfigurator.GetConfiguratorForService(GetType()); - } - - public void Dispose() - { - Stop(); - } - - public abstract void Process(string path, HttpCall call); - protected abstract void Start(); - protected abstract void Stop(); - - #region GET/SET - - private void SetEnabled(bool enabled) - { - if (Enabled == enabled) - return; - if (enabled) - Start(); - else - Stop(); - Configuration.Enabled = enabled; - } - - #endregion - - public SunfishServiceConfiguration Configuration { get; } - public abstract string Description { get; } - internal protected SunfishServiceConfigurator Configurator => configurator; - public bool Enabled { get => Configuration.Enabled; set => SetEnabled(value); } - } -} diff --git a/Sunfish/SunfishServiceConfiguration.cs b/Sunfish/SunfishServiceConfiguration.cs deleted file mode 100644 index 23c9144..0000000 --- a/Sunfish/SunfishServiceConfiguration.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Collections.Generic; - -namespace Sunfish -{ - public class SunfishServiceConfiguration - { - public T GetConf(string key, T def = default) - { - object value; - if (Settings.TryGetValue(key, out value)) - return (T)value; - return def; - } - - public string Type { get; set; } - public string Name { get; set; } - public bool Enabled { get; set; } - public string Location { get; set; } - public Dictionary Settings { get; set; } = new Dictionary(); - } -} diff --git a/Sunfish/_OLD_/AdminServiceConfigurator.cs b/Sunfish/_OLD_/AdminServiceConfigurator.cs deleted file mode 100644 index 9bf562a..0000000 --- a/Sunfish/_OLD_/AdminServiceConfigurator.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Sunfish.Configurator; - -namespace Sunfish.Services -{ - class AdminServiceConfigurator : SunfishServiceConfigurator - { - internal const string CFG_ADMIN_PWD = "adminPwd"; - - internal protected override ConfigurationScreen GetConfigurationScreen() - { - return new ConfigurationScreen() - { - Elements = new ConfigurationElement[] - { - new ConfigurationString(CFG_ADMIN_PWD,"Admin password: (blank for none)") - { - Tooltip="Password for the administration panel.\n Leave it blank for no password access (not recommended)", - IsPassword=true, - } - } - }; - } - } -} diff --git a/Sunfish/_OLD_/RemoteScreen.cs b/Sunfish/_OLD_/RemoteScreen.cs deleted file mode 100644 index ff06173..0000000 --- a/Sunfish/_OLD_/RemoteScreen.cs +++ /dev/null @@ -1,135 +0,0 @@ -using System; -using System.Drawing; -using System.Drawing.Imaging; -using System.IO; -using System.Runtime.InteropServices; -using System.Threading; -using System.Windows.Forms; - -namespace Sunfish.wx -{ - class RemoteScreen - { - private static ImageCodecInfo jpegEncoder = null; - private static long lastTime; - - static RemoteScreen() - { - foreach (ImageCodecInfo codec in ImageCodecInfo.GetImageDecoders()) - if (codec.FormatID == ImageFormat.Jpeg.Guid) - { - jpegEncoder = codec; - break; - } - } - - #region WINAPI - private const int MOUSEEVENTF_LEFTDOWN = 0x0002; /* left button down */ - private const int MOUSEEVENTF_LEFTUP = 0x0004; /* left button up */ - private const int MOUSEEVENTF_RIGHTDOWN = 0x0008; /* left button down */ - private const int MOUSEEVENTF_RIGHTUP = 0x0010; /* left button up */ - private const int MOUSEEVENTF_WHEEL = 0x0800; /* The wheel has been moved, if the mouse has a wheel. The amount of movement is specified in dwData */ - private const int KEYEVENTF_KEYDN = 0x0000; - private const int KEYEVENTF_EXTENDEDKEY = 0x0001; - private const int KEYEVENTF_KEYUP = 0x0002; - [DllImport("user32.dll", CharSet = CharSet.Auto, CallingConvention = CallingConvention.StdCall)] - public static extern void mouse_event(int dwFlags, int dx, int dy, int cButtons, int dwExtraInfo); - [DllImport("user32.dll")] - static extern uint keybd_event(byte bVk, byte bScan, int dwFlags, int dwExtraInfo); - public static void KeyDown(System.Windows.Forms.Keys key) - { - keybd_event((byte)key, 0, KEYEVENTF_EXTENDEDKEY | KEYEVENTF_KEYDN, 0); - } - - public static void KeyUp(System.Windows.Forms.Keys key) - { - keybd_event((byte)key, 0, KEYEVENTF_EXTENDEDKEY | KEYEVENTF_KEYUP /* 0x7F*/, 0); - } - #endregion - - public static byte[] GetScreen() - { - long diff = DateTime.Now.Ticks - lastTime; - long q; - if (diff > 25000000L) - q = 15; - else - { - q = 80 - (diff / 100000L); - if (q < 15) - q = 15; - } - Screen scr = Program.MAINFORM.MyScreen; - MemoryStream ms = new MemoryStream(); - using (Bitmap bmp = new Bitmap(scr.Bounds.Width, scr.Bounds.Height, PixelFormat.Format16bppRgb565)) - using (Graphics g = Graphics.FromImage(bmp)) - { - g.CopyFromScreen(scr.Bounds.X, scr.Bounds.Y, 0, 0, scr.Bounds.Size); - System.Drawing.Imaging.Encoder myEncoder = System.Drawing.Imaging.Encoder.Quality; - EncoderParameters myEncoderParameters = new EncoderParameters(1); - EncoderParameter myEncoderParameter = new EncoderParameter(myEncoder, q); - myEncoderParameters.Param[0] = myEncoderParameter; - bmp.Save(ms, jpegEncoder, myEncoderParameters); - ms.Close(); - } - lastTime = DateTime.Now.Ticks; - return ms.ToArray(); - } - - public static void InputCommand(string cmd) - { - if (cmd.Length == 0) - return; - string cc = cmd.Substring(0, 2); - string par = cmd.Substring(2); - if (cc.StartsWith("K")) - { - byte keyb; - byte.TryParse(par, out keyb); - Keys key = (Keys)Enum.ToObject(typeof(Keys), keyb); - switch (cc) - { - case "KD": - KeyDown(key); - break; - case "KU": - KeyUp(key); - break; - } - return; - } - int x; - int y; - string[] pars = par.Split(';'); - int.TryParse(pars[0], out x); - int.TryParse(pars[1], out y); - Screen scr = Program.MAINFORM.MyScreen; - System.Windows.Forms.Cursor.Position = new Point(x + scr.Bounds.X, y + scr.Bounds.Y); - switch (cc) - { - case "LD": - Thread.Sleep(100); - mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0); - break; - case "LU": - Thread.Sleep(100); - mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, 0); - break; - case "RD": - Thread.Sleep(100); - mouse_event(MOUSEEVENTF_RIGHTDOWN, 0, 0, 0, 0); - break; - case "RU": - Thread.Sleep(100); - mouse_event(MOUSEEVENTF_RIGHTUP, 0, 0, 0, 0); - break; - case "WH": - Thread.Sleep(100); - int wheel; - int.TryParse(pars[2], out wheel); - mouse_event(MOUSEEVENTF_WHEEL, 0, 0, wheel, 0); - break; - } - } - } -} diff --git a/Sunfish/app.config b/Sunfish/app.config deleted file mode 100644 index 51278a4..0000000 --- a/Sunfish/app.config +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/Sunfish/app.manifest b/Sunfish/app.manifest deleted file mode 100644 index ff3d8ae..0000000 --- a/Sunfish/app.manifest +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Sunfish/bin/Debug/$sunfish/$templates.html b/Sunfish/bin/Debug/$sunfish/$templates.html deleted file mode 100644 index 8da28f7..0000000 --- a/Sunfish/bin/Debug/$sunfish/$templates.html +++ /dev/null @@ -1,39 +0,0 @@ - - - {AppName} {AppVersion} - - - - - - - - - - - - {Include} - - - -
- -
- - -{%:sunfish-header}{%Items:directory-item}{%:sunfish-footer} - -
- - -
{Name}
-
{Description}
-
{@Actions}
{%Actions:action}
{/} -
- -{@Icon}{Icon}{/}{Name} diff --git a/Sunfish/bin/Debug/$sunfish/folder.png b/Sunfish/bin/Debug/$sunfish/folder.png deleted file mode 100644 index 39ec901..0000000 --- a/Sunfish/bin/Debug/$sunfish/folder.png +++ /dev/null Binary files differ diff --git a/Sunfish/bin/Debug/$sunfish/konsole.js b/Sunfish/bin/Debug/$sunfish/konsole.js deleted file mode 100644 index fd5ba0f..0000000 --- a/Sunfish/bin/Debug/$sunfish/konsole.js +++ /dev/null @@ -1,270 +0,0 @@ -function Konsole() { - var k = {}; - - function init() { - buildUI(); - install(); - function inject() { - buildStyles(); - document.body.appendChild(k.root); - } - if (document.body) - inject(); - else - window.addEventListener("load", inject); - } - - function build(def, into) { - var el; - if (!def.$) { - el = document.createTextNode(def); - if (into) - into.appendChild(el); - return el; - } - el = document.createElement(def.$); - var childs = def._; - delete def.$; - delete def._; - for (var i in def) { - if (i == "style") - for (var sn in def.style) - el.style[sn] = def.style[sn]; - else if (i == "event") - for (var en in def.event) - el.addEventListener(en, def.event[en]); - else - el[i] = def[i]; - } - if (childs) - if (typeof childs == "string") - el.appendChild(document.createTextNode(childs)); - else - for (var i in childs) - build(childs[i], el); - if (into) - into.appendChild(el); - return el; - } - - function buildStyles() { - k.style = build({ $: "style" }, document.head); - var rules = ""; - function rule(name, content) { - if (rules) - rules += "\n"; - rules += name + "{" + content + "}"; - } - rule("div#konsole", "all:initial;all:unset;position:fixed;top:0;left:0;width:0;height:0;font-family:sans-serif;z-index:1000"); - rule("div#konsole div#konsole-tab", "position:fixed;top:0;right:15px;width:20px;height:26px;text-align:center;padding-top:4px;background-color:#bd7a00;user-select:none;" + - "color:white;border-bottom-left-radius:10px;border-bottom-right-radius:2px;z-index:10;opacity:0.2;cursor:pointer;transition: opacity 0.2s, background-color 0.2s;" + - "font-size:16px;"); - rule("div#konsole div#konsole-tab:hover", "opacity:1;"); - rule("div#konsole div#konsole-tab.error", "opacity:1;background-color:tomato;"); - rule("div#konsole div#konsole-panel", "position:fixed;top:0;left:50px;right:50px;height:0%;background-color:#bd7a00;color:white;" + - "border-bottom-left-radius:30px;border-bottom-right-radius:5px;font-size:12px;overflow:hidden;transition: height 0.3s"); - rule("div#konsole div#konsole-panel.open", "height:98%"); - rule("div#konsole div#konsole-input-panel", "position:absolute;left:8px;right:8px;bottom:8px;"); - rule("div#konsole input#konsole-input", "width:100%;border-radius:5px;border-bottom-left-radius:20px;border-top-left-radius:10px"); - rule("div#konsole div#konsole-output", "position:absolute;top:0;left:8px;right:8px;bottom:44px;overflow:auto;background-color:white;padding-top:10px;" + - "border-bottom-left-radius:5px;border-bottom-right-radius:5px;color:black"); - rule("div#konsole div.konsole-sign", "display:inline-block;width:16px;text-align:center;font-weight:bold;vertical-align:top;"); - rule("div#konsole div.konsole-sign.error", "color:tomato"); - rule("div#konsole div.konsole-sign.input", "color:#00459e"); - rule("div#konsole div.konsole-sign.output", "color:#7bc012"); - rule("div#konsole div.konsole-block", "display:inline-block"); - k.style.innerHTML = rules; - } - - function buildUI() { - function kTabClick() { - if (k.panel.className == "") - open(); - else - close(); - } - function kInputKeypress(e) { - if (e.keyCode == 13) - try { - var cmd = k.in.value.trim(); - if (cmd == "") - return; - k.in.value = ""; - exec(cmd); - } catch (e) { - error(e); - } - } - k.root = build({ $: "div", id: "konsole" }); - k.tab = build({ $: "div", id: "konsole-tab", title: "Konsole", _: "K", event: { click: kTabClick } }, k.root); - k.panel = build({ $: "div", id: "konsole-panel" }, k.root); - k.inp = build({ $: "div", id: "konsole-input-panel" }, k.panel); - k.in = build({ $: "input", id: "konsole-input", event: { keypress: kInputKeypress } }, k.inp); - k.out = build({ $: "div", id: "konsole-output" }, k.panel); - } - - function install() { - if (!console) - console = {}; - function kk(name, type) { - console["_k_" + name] = console[name]; - console[name] = function (par) { - out(type, par); - console["_k_" + name](par); - } - } - kk("info", "i"); - kk("warn", "w"); - kk("log", "l"); - kk("debug", "d"); - kk("error", "e"); - window.addEventListener('error', function (e) { - k.tab.className = "error"; - var msg = e.message; - if (e.filename) - msg += "\n on " + e.filename + " (" + e.lineno + ":" + e.colno + ")"; - out("!", msg, e); - }); - } - - function rowDiv(type, child) { - var types = { - "!": "error", - ">": "input", - "<": "output", - } - var d = build({ $: "div", _: [{ $: "div", className: "konsole-sign " + types[type], _: type }] }, k.out); - var di = build({ $: "div", className: "konsole-block" }, d); - if (typeof child == "string") - di.innerText = child; - else - di.appendChild(child); - d.scrollIntoView(); - } - - function error(s) { - if (s.stack) - rowDiv("!", s.stack); - else - rowDiv("!", "" + s); - } - - function writeIn(s) { - rowDiv(">", s); - } - - function dump(o, into, expanded) { - if (o === null || o === undefined) { - var d = document.createElement("div"); - d.style.color = "#777777"; - d.innerText = "<" + o + ">"; - into.appendChild(d); - return; - } - switch (typeof o) { - case "string": { - var d = document.createElement("div"); - d.style.color = "#009900"; - d.innerText = '"' + o + '"'; - into.appendChild(d); - } - break; - case "null": - case "undefined": { - var d = document.createElement("div"); - d.style.color = "#999999"; - d.innerText = "<" + o + ">"; - into.appendChild(d); - } - break; - case "number": - case "boolean": { - var d = document.createElement("div"); - d.innerText = o; - into.appendChild(d); - } - break; - case "object": - if (expanded) { - var r = document.createElement("div"); - var d = document.createElement("span"); - d.innerText = "{"; - r.appendChild(d); - for (var k in o) { - d = document.createElement("div"); - var s = document.createElement("span"); - s.style.verticalAlign = "top"; - s.style.color = "#554455"; - s.innerText = k + ":"; - d.appendChild(s); - s = document.createElement("span"); - s.style.display = "inline-block"; - dump(o[k], s, false); - d.appendChild(s); - r.appendChild(d); - } - d = document.createElement("span"); - d.innerText = "}"; - r.appendChild(d); - into.appendChild(r); - } else { - var d = document.createElement("div"); - d.style.backgroundColor = "#C9C9C9"; - d.style.borderRadius = "5px"; - d.style.cursor = "pointer"; - d.innerText = ""; - into.appendChild(d); - d.addEventListener("click", function () { - into.removeChild(d); - dump(o, into, true); - }); - } - break; - case "function": { - var d = document.createElement("div"); - d.style.color = "#000099"; - d.innerText = ""; - into.appendChild(d); - } - break; - default: - throw new Error("Cómo: " + typeof o) - } - } - - function out(type, data, extended) { - var row = build({ $: "div", className: "konsole-block object" }); - dump(data, row, true); - rowDiv(type, row); - } - - function exec(cmd) { - writeIn(cmd); - out("<", eval(cmd)); - } - - function showTab() { - k.root.appendChild(k.tab); - } - - function hideTab() { - k.root.removeChild(k.tab); - } - - function open(obj) { - k.panel.className = "open"; - k.tab.className = ""; - } - - function close() { - k.panel.className = ""; - } - - init(); - this.open = open; - this.close = close; - this.showTab = showTab; - this.hideTab = hideTab; -} - -var konsole = new Konsole(); \ No newline at end of file diff --git a/Sunfish/bin/Debug/$sunfish/logo.png b/Sunfish/bin/Debug/$sunfish/logo.png deleted file mode 100644 index 82fc5d2..0000000 --- a/Sunfish/bin/Debug/$sunfish/logo.png +++ /dev/null Binary files differ diff --git a/Sunfish/bin/Debug/$sunfish/style.css b/Sunfish/bin/Debug/$sunfish/style.css deleted file mode 100644 index 388a3a9..0000000 --- a/Sunfish/bin/Debug/$sunfish/style.css +++ /dev/null @@ -1,355 +0,0 @@ -html { - margin: 0; - padding: 0; - background: #b9b9b9; -} - -body { - margin: 0; - padding: 0; - width: 100%; - background: white; -} - -a { - text-decoration: none; - color: black; -} - -* { - box-sizing: border-box; -} - -a:hover { - color: #5d6962; -} - -#head { - height: 4.7rem; - background: white url(/$sunfish/logo.png) 1rem 1rem no-repeat; - padding: 1rem; - position: relative; -} - -#main-title { - font-family: 'Montserrat', Verdana, sans-serif; - font-size: 2.2rem; - left: 4.5rem; - top: 0.3rem; - position: absolute; -} - -#main-subtitle { - position: absolute; - font-family: 'Montserrat', Verdana, sans-serif; - font-size: 1.1rem; - color: #000000b2; - top: 1.3rem; - left: 13rem; -} - -#main-location { - position: absolute; - bottom: 0; - left: 4.5rem; - right: 0; - font-family: 'Open Sans', sans-serif; - font-size: 0.9rem; - color: #696969; - background: #a6e1ff; - padding: 0.2rem; - border-top-left-radius: 0.6rem; - border-bottom-left-radius: 0.6rem; -} - -#main-location>a::after { - content: " / "; - color: #777777; -} - -#main-location>a>i.material-icons-round { - font-size: 1.3rem; - vertical-align: bottom; -} - -#main-toolbar { - position: absolute; - right: 4rem; - top: 0.8rem; -} - -#main-toolbar i.material-icons-round { - font-size: 26px; - cursor: pointer; - color: dimgrey; -} - -#main-toolbar i.material-icons-round:hover { - font-size: 26px; - cursor: pointer; - color: #818181; -} - -#wrapper { - margin: 0 auto; - padding: 0.5rem; - font-family: 'Roboto Condensed', sans-serif; - text-align: center; -} - -.item { - position: relative; - cursor: pointer; - cursor: hand; - padding: 0.5rem; - margin: 5px 5px 9px 5px; - width: 22.5rem; - max-width: calc(100% - 2rem); - display: inline-block; - border-bottom: 1px solid #DEDEDE; - text-align: initial; - height: 3rem; -} - -.item:hover { - background-color: #ecf9ff; - border-radius: 0.2rem; -} - -.item-icon { - position: absolute; - top: 0.5rem; - left: 0.5rem; - max-width: 32px; - max-height: 32px; -} - -.item-name { - position: absolute; - top: 0.3rem; - left: 3rem; - font-weight: 500; - font-size: 0.9rem; -} - -.item-desc { - position: absolute; - bottom: 0.5rem; - left: 4rem; - font-weight: 400; - font-size: 0.7rem; - color: gray; -} - -.item-actions { - position: absolute; - bottom: 0.1rem; - right: 0rem; - padding-right: 0.5rem; -} - -.item-actions i.material-icons-round { - font-size: 1.1rem; - color: #aaaaaa; - margin-left: 0.3rem; -} - -.test { - background-color: #4f6e05; -} - -.item.active { - cursor: pointer; -} - -.item>a { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; -} - -.desktop .item-actions { - display: none; -} - -.desktop .item:hover .item-actions { - display: unset; -} - -.popup { - position: fixed; - top: 50%; - left: 50%; - width: 30rem; - max-width: 90%; - background-color: rgba(39, 36, 31, 0.822); - font-family: 'Roboto Condensed', sans-serif; - font-weight: bold; - color: white; - padding: 1rem; - margin-left: -15rem; - margin-top: -5rem; - border-radius: 0.5rem; -} - -.popup.error { - background-color: rgba(116, 11, 3, 0.822); -} - -.popup>.body { - padding-bottom: 1rem; - font-size: 1.1rem; -} - -.popup>.buttons { - text-align: right; -} - -.popup>.body>.input>input { - display: block; - width: 100%; -} - -.popup-wall { - z-index: 200; - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 0, 0, 0.199); -} - -button { - background-color: #e2d9cd; - font-family: 'Open Sans', sans-serif; - font-weight: bold; - color: #424242; - border: 0; - border-radius: 1rem; - margin-left: 0.5rem; - padding: 0.3rem 1rem; - outline: none; -} - -button:hover { - background-color: #beb7ae; -} - -button.warning { - background-color: tomato; -} - -button.warning:hover { - background-color: #d4523b; -} - -input { - border: 0; - background-color: whitesmoke; - border-radius: 1rem; - font-family: 'Open Sans', sans-serif; - margin-top: 1rem; - padding: 0.3rem 1rem; - outline: none; -} - -input:hover { - background-color: #cecece; -} - -.upload-drop { - background-color: rgba(0, 0, 0, 0.75); - display: none; -} - -.upload-drop-icon { - position: absolute; - top: 5%; - height: 90%; - left: 25%; - width: 50%; - bottom: 25%; - border: 10px dashed #c1c1c1; - border-radius: 2rem; - vertical-align: middle; - overflow: hidden; -} - -.upload-drop-icon>i.material-icons-round { - width: 100%; - color: white; - height: 100%; - vertical-align: middle; - font-size: 8rem; - margin-top: 35%; - text-align: center; -} - -.upload-drop-icon>i.material-icons-round::before { - content: "Drop here to upload"; - position: absolute; - top: 58%; - left: 0; - right: 0; - font-family: 'Open Sans'; - font-size: 1rem; -} - -.upload-list { - height: calc( 100% - 4rem); - overflow-y: auto; - padding: 0 1rem; - margin: 1rem 0; -} - -.upload-progress-box { - height: 2rem; - background: #383838; - position: relative; -} - -.upload-progress { - position: absolute; - top: 0; - left: 0; - bottom: 0; - background: #a6e1ff; -} - -.upload-item { - font-family: 'Open Sans'; - color: white; - margin-bottom: 1rem; -} - -.upload-item-progress-box { - height: 1rem; - background: #383838; - border-radius: 0.5rem; - margin-top: 0.2rem; - border: 1px solid black; - position: relative; - overflow: hidden; -} - -.upload-item-progress { - position: absolute; - top: 0; - left: 0; - bottom: 0; - background: #a6e1ff url(/$sunfish/logo.png) 100% 0.05rem/0.8rem 0.8rem no-repeat; -} - -.upload-close { - margin-top: 0.15rem; - background: #6d6d6d; - color: white; - margin-left: calc( 100% - 5rem); -} - -body.dragging .upload-drop{ - display: block; -} \ No newline at end of file diff --git a/Sunfish/bin/Debug/$sunfish/sunfish-directory.js b/Sunfish/bin/Debug/$sunfish/sunfish-directory.js deleted file mode 100644 index 0a6e991..0000000 --- a/Sunfish/bin/Debug/$sunfish/sunfish-directory.js +++ /dev/null @@ -1,327 +0,0 @@ -(function () { - - /** - * Get the item element form any sub element - * @param {*} subElement DOM element child of item - */ - function getItem(subElement) { - while (subElement.className.indexOf("item-row") < 0) - subElement = subElement.parentElement; - return subElement; - } - - sunfish.deleteFile = function (sender) { - //TODO: use the DELTE http method - var ask; - var item = getItem(sender); - var isfolder = item.className.indexOf("directory") >= 0; - var file = item.querySelector("div.item-name").innerText; - if (isfolder) - ask = "Sure to delete folder " + file + " and all its contents? can not be undone."; - else - ask = "Sure to delete " + file + "? can not be undone."; - sunfish.ask(ask, - { b: 'Cancel' }, - { - b: 'Delete', class: 'warning', do: function () { - var unlock = sunfish.lock("deleting..."); - sunfish.ajax(document.location.href + file + "?action=delete", { - ok: function (result) { - if (result == "OK") { - unlock(); - item.parentNode.removeChild(item); - } else { - unlock(); - sunfish.error("Error deleting file", function () { - document.location.reload(); - }); - } - } - }) - } - } - - ); - } - - sunfish.renameFile = function (sender) { - var item = getItem(sender); - var file = item.querySelector("div.item-name").innerText; - sunfish.askString('Rename to:', file, function (newName) { - sunfish.ajax(document.location.href + file + "?action=rename&to=" + newName, { - ok: function (result) { - if (result == "OK") { - item.querySelector("div.item-name").innerText = newName; - } else - sunfish.error("Error renaming file", function () { - document.location.reload(); - }); - } - }); - }); - } - - sunfish.openFile = function (sender) { - var item = getItem(sender); - var file = item.querySelector("div.item-name").innerText; - sunfish.ajax(document.location.href + file + "?action=open", { - ok: function (result) { - if (result != "OK") - sunfish.error("Error opening file in server", function () { - document.location.reload(); - }); - } - }); - } - - sunfish.newFolder = function (sender) { - sunfish.askString('Folder name:', "", function (name) { - if (!name) - return; - sunfish.ajax(document.location.href + name + '/', { - method: "PUT", - ok: function (result) { - if (result != "OK") - sunfish.error("Error creading file", function () { - document.location.reload(); - }); - else - document.location.reload(); - } - }); - }); - } - - sunfish.newFile = function (sender) { - sunfish.askString('File name:', "", function (name) { - if (!name) - return; - sunfish.ajax(document.location.href + name, { - method: "PUT", - ok: function (result) { - if (result != "OK") - sunfish.error("Error creading file", function () { - document.location.reload(); - }); - else - document.location.reload(); - } - }); - }); - } - - sunfish.uploadFile = function (sender) { - var efile = document.createElement("input"); - efile.type = "file"; - efile.onchange = () => { - var fileList = efile.files; - for (var i = 0; i < fileList.length; i++) - upl.upload(fileList[i]); - } - efile.click(); - } - - // UPLOAD task - var upl; - function Uploader() { - var pb, elist, uploads = []; - var edrop = sunfish.build({ - $: "div", className: "popup-wall upload-drop", _: [ - { - $: "div", className: "upload-drop-icon", _: [ - { $: "i", className: "material-icons-round", _: ["upload"] } - ] - } - ] - }); - var ctt = edrop.querySelector("div.upload-drop-icon"); - - document.body.appendChild(edrop); - - function show() { - document.body.appendChild(edrop); - var cbutton; - ctt.innerHTML = ""; - elist = sunfish.build({ $: "div", className: "upload-list" }, ctt); - elist.addItem = function (file, name, to) { - if (!pb.updater) { - pb.updater = function () { - var tpos = 0, tmax = 0; - uploads.forEach(u => { - tpos += u.pos; - tmax += u.length; - }) - if (tmax == 0) - tmax = 100; - pb.style.width = (tpos / tmax * 100) + "%"; - if (tpos < tmax) { - setTimeout(pb.updater, 100); - cbutton.style.display = "none"; - } else { - delete pb.updater; - cbutton.style.display = ""; - } - } - setTimeout(pb.updater, 100); - } - var item = sunfish.build({ - $: "div", className: "upload-item", _: [{ $: "div", _: [to] }, - { $: "div", className: "upload-item-progress-box", _: [{ $: "div", className: "upload-item-progress" }] }] - }, elist); - var itemProgress = item.querySelector(".upload-item-progress"); - var me; - uploads.push(me = { - file: file, - name: name, - to: to, - progress: itemProgress, - length: file.size, - pos: 0, - eof: false - }); - function updateProgress() { - if (me.length <= 0) - return; - var pos = me.pos; - if (pos > me.length) - pos = me.length; - itemProgress.style.width = (pos / me.length) * 100 + "%"; - - } - function ko(info) { - itemProgress.style.width = "150%"; - itemProgress.style.backgroundColor = "tomato"; - if (info) - console.error(info); - me.pos = me.length; - } - function step() { - var length = Math.min(1024 * 512, me.length - me.pos); - var blob = file.slice(me.pos, me.pos + length); - if (blob.length == 0) - return ko("Nothing to send"); - sunfish.ajax(document.location.href + to, { - method: "PUT", - data: blob, - headers: { - "X-Sunfish-Offset": me.pos, - "X-Sunfish-Length": blob.size - }, - ok: function (result) { - if (result != "OK") - ko("Upload is " + result); - else { - me.pos += blob.size; - updateProgress(); - if (me.pos < me.length) - step(); - else { - itemProgress.style.backgroundColor = "#46ad33"; - me.eof = true; - } - } - }, - ko: ko - }); - } - step(); - } - var pbb = sunfish.build({ $: "div", className: "upload-progress-box" }, ctt); - pb = sunfish.build({ - $: "div", className: "upload-progress", _: [ - { - $: "button", className: "upload-close", style: { display: "none" }, _: ["Close"], onclick: function () { - /* - document.body.removeChild(edrop); - uploads = []; - elist.innerHTML=""; - cbutton.style.display = "none"; - pb.style.width="0"; - */ - document.location.reload(); - } - } - ] - }, pbb); - cbutton = pb.querySelector(".upload-close"); - } - - document.body.addEventListener("drop", function (ev) { - function processWKItem(item, path) { - path = path || ""; - if (item.isFile) { - item.file(function (file) { - upload(file, path + file.name); - }); - } else if (item.isDirectory) { - // Get folder contents - var dirReader = item.createReader(); - dirReader.readEntries(function (entries) { - for (var i = 0; i < entries.length; i++) { - processWKItem(entries[i], path + item.name + "/"); - } - }); - } - } - ev.preventDefault(); - if (ev.dataTransfer.items) { - var items = ev.dataTransfer.items; - for (var i = 0; i < items.length; i++) { - var item = items[i]; - if (item.kind === 'file') - if (item.webkitGetAsEntry) - processWKItem(item.webkitGetAsEntry()); - else - upload(item.getAsFile()); - } - } else { - for (var i = 0; i < ev.dataTransfer.files.length; i++) - upload(ev.dataTransfer.files[i].getAsFile()); - } - }); - document.body.addEventListener("dragover", function (ev) { - console.info("drag-over"); - ev.stopPropagation(); - ev.preventDefault(); - var anyfile = false; - if (ev.dataTransfer.items) { - for (var i = 0; i < ev.dataTransfer.items.length; i++) - if (anyfile = ev.dataTransfer.items[i].kind == "file") - break; - } else - anyfile = ev.dataTransfer.files.length > 0; - if (anyfile) - document.body.classList.add("dragging"); - }); - - edrop.addEventListener("dragleave", function (ev) { - console.info("drag-leave"); - ev.stopPropagation(); - ev.preventDefault(); - var anyfile = false; - if (ev.dataTransfer.items) { - for (var i = 0; i < ev.dataTransfer.items.length; i++) - if (anyfile = ev.dataTransfer.items[i].kind == "file") - break; - } else - anyfile = ev.dataTransfer.files.length > 0; - if (anyfile) - document.body.classList.remove("dragging"); - }); - - function upload(file, to) { - document.body.appendChild(edrop); - if (!elist) - show(); - to = to || file.name; - elist.addItem(file, file.name, to); - } - - this.upload = upload; - } - - window.addEventListener("load", function () { - upl = new Uploader(); - }); - -})(); \ No newline at end of file diff --git a/Sunfish/bin/Debug/$sunfish/sunfish.js b/Sunfish/bin/Debug/$sunfish/sunfish.js deleted file mode 100644 index 46684d5..0000000 --- a/Sunfish/bin/Debug/$sunfish/sunfish.js +++ /dev/null @@ -1,183 +0,0 @@ -function Sunfish() { - var mobile; - function init() { - mobileCheck = function () { - var check = false; - (function (a) { - if (/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od|ad)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a) || /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0, 4))) check = true; - })(navigator.userAgent || navigator.vendor || window.opera); - return check; - }; - mobile = mobileCheck(); - document.body.className = mobile ? "mobile" : "desktop"; - konsole.showTab(); - } - - function build(def, into) { - var el; - if (!def.$) { - el = document.createTextNode(def); - if (into) - into.appendChild(el); - return el; - } - el = document.createElement(def.$); - var childs = def._; - delete def.$; - delete def._; - for (var i in def) { - if (i == "style") - for (var sn in def.style) - el.style[sn] = def.style[sn]; - else - el[i] = def[i]; - } - if (childs) - if (typeof childs == "string") - el.appendChild(document.createTextNode(childs)); - else - for (var i in childs) - build(childs[i], el); - if (into) - into.appendChild(el); - return el; - } - - function dialog(content, buttons, classes) { - var btdef = []; - for (var i in buttons) { - var bt = buttons[i]; - if (bt.$) - btdef.push(bt); - else - btdef.push({ - $: "button", - className: bt.class, - _: bt.b, - onclick: bt.do - }); - } - - var wall = build({ $: "div", className: "popup-wall" }, document.body); - var def = { - $: "div", className: "popup" + (classes ? " " + classes : ""), _: [ - { $: "div", className: "body", _: content }, - { $: "div", className: "buttons", _: btdef } - ] - }; - var dialog = build(def, wall); - dialog.close = function () { - document.body.removeChild(wall); - } - return dialog; - } - - function ask(question, bt1, bt2, bt3, bt4, bt5, iserror) { - var buttons = []; - var el; - function addbt(bt) { - if (bt) { - if (bt.go) - bt.do = function () { document.location = bt.go }; - buttons.push({ - class: bt.class, - b: bt.b, - do: function () { - bt.do && bt.do(); - el.close(); - } - }); - } - } - addbt(bt1); - addbt(bt2); - addbt(bt3); - addbt(bt4); - addbt(bt5); - return el = dialog([question], buttons, iserror ? "error" : null); - } - - function askString(text, def, cb) { - var input; - var dialog = ask({ $: "div", className: "input", _: [text, { $: "input", id: "askInput", value: def }] }, { b: 'Cancel' }, { - b: 'Ok', class: "ok", do: function () { - var value = input.value; - cb && cb(value); - } - }); - input = dialog.querySelector("input#askInput"); - input.focus(); - input.selectionStart = 0; - input.selectionEnd = input.value.length; - input.onkeypress = function (e) { - if (e.keyCode == 13) - dialog.querySelector("button.ok").click(); - }; - } - - function say(text, cb) { - ask(text, { b: 'Ok', do: cb }); - } - - function error(text, cb) { - ask(text, { b: 'Ok', do: cb }, null, null, null, null, true); - } - - function lock(text) { - var wall = build({ $: "div", className: "popup-wall" }, document.body); - var def = { - $: "div", className: "popup", _: [ - { $: "div", className: "body", _: [text] } - ] - }; - build(def, wall); - return function () { - document.body.removeChild(wall); - } - } - - function go(to) { - document.location = to - } - - function ajax(url, ctrl) { - var xhr = new XMLHttpRequest(); - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) - ctrl.ok && ctrl.ok(xhr.responseText, xhr); - }; - xhr.open(ctrl.method || 'GET', url); - if (ctrl.headers) - for (var k in ctrl.headers) - xhr.setRequestHeader(k, ctrl.headers[k]); - if (ctrl.binary) - xhr.sendAsBinary(ctrl.binary, "binary/octet"); - else - xhr.send(ctrl.data); - } - - if (!XMLHttpRequest.prototype.sendAsBinary) - XMLHttpRequest.prototype.sendAsBinary = function (datastr, contentType) { - var bb = new BlobBuilder(); - var len = datastr.length; - var data = new Uint8Array(len); - for (var i = 0; i < len; i++) { - data[i] = datastr.charCodeAt(i); - } - bb.append(data.buffer); - this.send(bb.getBlob(contentType)); - } - - this.init = init; - this.build = build; - this.dialog = dialog; - this.ask = ask; - this.askString = askString; - this.say = say; - this.error = error; - this.lock = lock; - this.go = go; - this.ajax = ajax; -} - -var sunfish = new Sunfish(); diff --git a/Sunfish/sunfishWebServer.ico b/Sunfish/sunfishWebServer.ico deleted file mode 100644 index f79f660..0000000 --- a/Sunfish/sunfishWebServer.ico +++ /dev/null Binary files differ diff --git a/Sunfish/uHttp.cs b/Sunfish/uHttp.cs deleted file mode 100644 index 46feb31..0000000 --- a/Sunfish/uHttp.cs +++ /dev/null @@ -1,164 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; -using System.Net; -using System.IO; -using System.Drawing; -using XWolf; - - -namespace Umbrella -{ - class uHttp : HttpServerProcessor - { - static byte[] logo; - - static uHttp(){ - MemoryStream ms=new MemoryStream(); - Properties.Resources.logo.Save(ms, System.Drawing.Imaging.ImageFormat.Png); - ms.Close(); - logo = ms.ToArray(); - } - - private void Header() - { - Write("Umbrella"); - Write(""); - Write(""); - Write("
Umbrella" + Program.version + "
"); -#if TRACE - Write(" TRACE "); -#endif -#if DEBUG - Write(" DEBUG "); -#endif - Write("

"); - //Listado servicios - Write(""); - foreach (uService serv in uServices.Services) - { - Write(""); - } - Write("
Servicios:
" + serv.Name + "
"); - } - - private void Foot() - { - Out.Write("
"); - } - - override protected void Process(){ - try - { - if (Path.StartsWith("/service_")) - { - doService(Path.Substring(1)); - return; - } - } - catch (Exception e) - { - doError(e); - return; - } - switch (Path.ToLower()) - { - case "/": - doHome(); - break; - case "/favicon.ico": - doFavicon(); - break; - case "/logo": - doLogo(); - break; - case "/bg": - doBack(); - break; - default: - Error404(); - break; - } - } - - private void doHome() - { - Header(); - - - - //misc - Foot(); - } - - private void doService(string srid) - { - uService srv=uServices.ByPath(srid); - if (srv == null) - throw new Exception("No existe el servicio"); - if (srv.Acciona(this)) - doGo(Path); - else - { - srv.setPath(srid); - if (srv.rsubpath == "") - { - Header(); - srv.Execute(); - Foot(); - } - else if (srv.rsubpathbin) - srv.Execute(srv.rsubpath); - else - { - Header(); - srv.Execute(srv.rsubpath); - Foot(); - } - } - } - - private void doError(Exception e) - { - Header(); - Write(""+e.GetType().FullName+": "+e.Message+"

"); - Write(nl2br(e.StackTrace)); - Write("


Indice"); - Foot(); - } - - private void doFavicon() - { - Response.Headers[HttpResponseHeader.ContentType] = "image/vnd.microsoft.icon"; - MemoryStream ms=new MemoryStream(); - Properties.Resources.ico.Save(ms); - Write(ms.ToArray()); - ms.Close(); - } - - private void doBack() - { - Response.Headers[HttpResponseHeader.ContentType] = "image/jpeg"; - Write(loadBG()); - } - - private void doLogo() - { - Response.Headers[HttpResponseHeader.ContentType] = "image/png"; - Write(logo); - } - - private void doGo(string href) - { - Write(""); - } - - private byte[] loadBG() - { - MemoryStream ms = new MemoryStream(); - Properties.Resources.ubg.Save(ms, System.Drawing.Imaging.ImageFormat.Jpeg); - ms.Close(); - return ms.ToArray(); - } - } -} diff --git a/art/folder.png b/art/folder.png new file mode 100644 index 0000000..39ec901 --- /dev/null +++ b/art/folder.png Binary files differ diff --git a/art/logo.png b/art/logo.png new file mode 100644 index 0000000..82fc5d2 --- /dev/null +++ b/art/logo.png Binary files differ diff --git a/art/sunfish.ico b/art/sunfish.ico new file mode 100644 index 0000000..f79f660 --- /dev/null +++ b/art/sunfish.ico Binary files differ diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..58abc32 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,183 @@ +# Sunfish - autocompilacion para Windows (PowerShell 5.1 o superior). +# +# irm https://raw.githubusercontent.com/XWolfOverride/Sunfish/main/build.ps1 | iex +# +# Que hace: +# 1. Comprueba que estan instaladas las herramientas necesarias (NO instala nada). +# 2. Descarga (o actualiza) la ultima version del codigo con git. +# 3. Compila Sunfish.exe (ventana, un solo .exe) y sunfish-cli.exe (consola, NativeAOT). +# 4. Deja los dos en la raiz del proyecto y te explica como instalar sunfish-cli. +# +# Variables opcionales: +# $env:SUNFISH_REPO_URL URL del repositorio (util para forks) +# $env:SUNFISH_LIGHT = '1' Sunfish.exe ligero: necesita el .NET Desktop Runtime 10 instalado +# +# Nota: todo va dentro de una funcion y se sale con `return`, para que al ejecutarlo con +# `| iex` un fallo no cierre tu ventana de PowerShell. + +function Invoke-SunfishBuild { + $ErrorActionPreference = 'Stop' + + $repoUrl = if ($env:SUNFISH_REPO_URL) { $env:SUNFISH_REPO_URL } else { 'https://github.com/XWolfOverride/Sunfish' } + $dirName = 'Sunfish' + $light = $env:SUNFISH_LIGHT -eq '1' + + function Ok($m) { Write-Host "[OK] " -ForegroundColor Green -NoNewline; Write-Host $m } + function Bad($m) { Write-Host "[X] " -ForegroundColor Red -NoNewline; Write-Host $m } + function Info($m) { Write-Host ">> " -ForegroundColor Cyan -NoNewline; Write-Host $m } + + Write-Host "" + Write-Host "Sunfish: autocompilacion" -ForegroundColor White + Write-Host "" + + # ---------------------------------------------------------- arquitectura + $arch = $env:PROCESSOR_ARCHITEW6432 + if (-not $arch) { $arch = $env:PROCESSOR_ARCHITECTURE } + switch ($arch) { + 'AMD64' { $rid = 'win-x64' } + 'ARM64' { $rid = 'win-arm64' } + default { Write-Host "Arquitectura no soportada por el script: $arch" -ForegroundColor Red; return } + } + Info "Sistema detectado: $rid" + Write-Host "" + + # ---------------------------------------------------------- prerrequisitos + $missing = @() + $hints = @() + + if (Get-Command git -ErrorAction SilentlyContinue) { + Ok ("git (" + ((git --version) -replace 'git version ', '') + ")") + } else { + Bad 'git'; $missing += 'git'; $hints += 'winget install Git.Git' + } + + $dotnetOk = $false + if (Get-Command dotnet -ErrorAction SilentlyContinue) { + $ver = (& dotnet --version 2>$null) + $major = 0 + if ($ver -and [int]::TryParse(($ver -split '\.')[0], [ref]$major) -and $major -ge 10) { + Ok "SDK de .NET ($ver)"; $dotnetOk = $true + } else { + Bad "SDK de .NET: version $ver, se necesita 10 o superior" + } + } else { + Bad 'SDK de .NET 10' + } + if (-not $dotnetOk) { $missing += 'SDK de .NET 10'; $hints += 'winget install Microsoft.DotNet.SDK.10' } + + # Compilador nativo para sunfish-cli (NativeAOT): Build Tools / Visual Studio con C++. + $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' + $hasCpp = $false + if (Test-Path $vswhere) { + $vsPath = & $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath 2>$null + if ($vsPath) { $hasCpp = $true } + } + if ($hasCpp) { + Ok 'Compilador de C++ (Visual Studio Build Tools)' + # ILCompiler invoca "vswhere.exe" por nombre: nos aseguramos de que este en el PATH de este proceso. + $vsDir = Split-Path $vswhere + if (($env:PATH -split ';') -notcontains $vsDir) { $env:PATH = "$env:PATH;$vsDir" } + } else { + Bad 'Compilador de C++ (Visual Studio Build Tools con "Desktop development with C++")' + $missing += 'Compilador de C++' + $hints += 'winget install Microsoft.VisualStudio.2022.BuildTools --override "--passive --wait --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"' + } + + if ($missing.Count -gt 0) { + Write-Host "" + Write-Host ("Faltan herramientas necesarias: " + ($missing -join ', ')) -ForegroundColor Yellow + Write-Host "" + Write-Host "Instalalas con (este script no instala nada por ti):" + Write-Host "" + foreach ($h in $hints) { Write-Host " $h" } + Write-Host "" + Write-Host "Cierra y vuelve a abrir PowerShell tras instalarlas y repite este comando." + Write-Host "Detalles en el README del proyecto." + return + } + + # ---------------------------------------------------------- codigo fuente + Write-Host "" + $here = (Get-Location).Path + + function Update-Repo($path) { + # Sin remoto (copia local aun sin publicar) no hay nada que actualizar. + if (-not (git -C $path remote)) { + Info "El repositorio no tiene remoto configurado: se compila el codigo tal como esta." + return $true + } + git -C $path pull --ff-only + if ($LASTEXITCODE -ne 0) { + Write-Host "No se pudo actualizar (hay cambios locales?). Resuelvelo y repite." -ForegroundColor Red + return $false + } + return $true + } + + if ((Test-Path (Join-Path $here 'Sunfish.slnx')) -and (Test-Path (Join-Path $here '.git'))) { + $root = $here + Info "Usando el repositorio actual: $root" + if (-not (Update-Repo $root)) { return } + } elseif (Test-Path (Join-Path $here "$dirName\.git")) { + $root = Join-Path $here $dirName + Info "Actualizando $root" + if (-not (Update-Repo $root)) { return } + } else { + $root = Join-Path $here $dirName + Info "Descargando $repoUrl" + git clone --depth 1 $repoUrl $root + if ($LASTEXITCODE -ne 0) { Write-Host "No se pudo clonar el repositorio." -ForegroundColor Red; return } + } + + # ---------------------------------------------------------- compilacion + $env:DOTNET_NOLOGO = '1' + $pub = Join-Path $root 'publish' + if (Test-Path $pub) { Remove-Item $pub -Recurse -Force } + + # Sunfish.exe: un solo .exe (WinForms no admite NativeAOT). + $gui = Join-Path $pub 'gui' + if ($light) { + Info "Compilando Sunfish.exe ligero ($rid, necesita el .NET Desktop Runtime 10)..." + dotnet publish (Join-Path $root 'src\Sunfish.WinForms\Sunfish.WinForms.csproj') -c Release -r $rid -o $gui -p:SelfContained=false -nologo -v q + } else { + Info "Compilando Sunfish.exe autocontenido ($rid)..." + dotnet publish (Join-Path $root 'src\Sunfish.WinForms\Sunfish.WinForms.csproj') -c Release -r $rid -o $gui -nologo -v q + } + if ($LASTEXITCODE -ne 0) { Write-Host "Fallo la compilacion de Sunfish.exe." -ForegroundColor Red; return } + Copy-Item (Join-Path $gui 'Sunfish.exe') (Join-Path $root 'Sunfish.exe') -Force + Ok "Sunfish.exe -> $(Join-Path $root 'Sunfish.exe')" + + # sunfish-cli.exe: NativeAOT. + $cli = Join-Path $pub 'cli' + Info "Compilando sunfish-cli.exe (NativeAOT, $rid)... puede tardar un par de minutos" + dotnet publish (Join-Path $root 'src\Sunfish.Cli\Sunfish.Cli.csproj') -c Release -r $rid -o $cli -nologo -v q + if ($LASTEXITCODE -ne 0) { Write-Host "Fallo la compilacion de sunfish-cli.exe." -ForegroundColor Red; return } + Copy-Item (Join-Path $cli 'sunfish-cli.exe') (Join-Path $root 'sunfish-cli.exe') -Force + Ok "sunfish-cli.exe -> $(Join-Path $root 'sunfish-cli.exe')" + + Remove-Item $pub -Recurse -Force + + # ---------------------------------------------------------- final + Write-Host "" + Write-Host "Listo." -ForegroundColor Green + foreach ($name in 'Sunfish.exe', 'sunfish-cli.exe') { + $f = Get-Item (Join-Path $root $name) + Write-Host (" {0,8:N1} MB {1}" -f ($f.Length / 1MB), $f.FullName) + } + Write-Host "" + Write-Host "Sunfish.exe se abre con doble clic. Arrastra una carpeta sobre el para compartirla al momento." -ForegroundColor White + Write-Host "" + Write-Host "Para usar sunfish-cli desde cualquier terminal (sin administrador), ejecuta:" -ForegroundColor White + Write-Host "" + Write-Host " cd `"$root`"" + Write-Host ' mkdir $HOME\bin -Force | Out-Null' + Write-Host ' copy sunfish-cli.exe $HOME\bin' + Write-Host ' $p = [Environment]::GetEnvironmentVariable("Path", "User")' + Write-Host ' [Environment]::SetEnvironmentVariable("Path", "$p;$HOME\bin", "User")' + Write-Host "" + Write-Host "Cierra y vuelve a abrir la terminal y pruebalo con: sunfish-cli --help" + Write-Host "Para actualizar a una nueva version, vuelve a ejecutar este script." + Write-Host "" +} + +Invoke-SunfishBuild diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..a506947 --- /dev/null +++ b/build.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +# +# Sunfish — autocompilación de sunfish-cli (NativeAOT) para Linux y macOS. +# (Sunfish.exe, la ventana, es solo para Windows: ver build.ps1.) +# +# curl -fsSL https://raw.githubusercontent.com/XWolfOverride/Sunfish/main/build.sh | bash +# +# Qué hace: +# 1. Comprueba que están instaladas las herramientas necesarias (NO instala nada). +# 2. Descarga (o actualiza) la última versión del código con git. +# 3. Compila `sunfish-cli` con NativeAOT. +# 4. Deja el binario en la raíz del proyecto y te explica cómo instalarlo. +# +# Variables opcionales: +# SUNFISH_REPO_URL URL del repositorio (útil para forks) + +set -euo pipefail + +REPO_URL="${SUNFISH_REPO_URL:-https://github.com/XWolfOverride/Sunfish}" +DIR_NAME="Sunfish" + +if [ -t 1 ]; then + B=$'\e[1m'; G=$'\e[32m'; R=$'\e[31m'; Y=$'\e[33m'; C=$'\e[36m'; N=$'\e[0m' +else + B=''; G=''; R=''; Y=''; C=''; N='' +fi + +ok() { printf '%s✔%s %s\n' "$G" "$N" "$*"; } +bad() { printf '%s✖%s %s\n' "$R" "$N" "$*"; } +info() { printf '%s»%s %s\n' "$C" "$N" "$*"; } +die() { printf '\n%s%s%s\n' "$R" "$*" "$N" >&2; exit 1; } + +echo +echo "${B}Sunfish — autocompilación${N}" +echo + +# ---------------------------------------------------------------- sistema +OS="$(uname -s)" +case "$OS" in + Linux) PREFIX="linux" ;; + Darwin) PREFIX="osx" ;; + *) die "Sistema no soportado por este script: $OS. En Windows usa build.ps1." ;; +esac + +case "$(uname -m)" in + x86_64|amd64) RID="$PREFIX-x64" ;; + aarch64|arm64) RID="$PREFIX-arm64" ;; + *) die "Arquitectura no soportada por el script: $(uname -m)" ;; +esac +info "Sistema detectado: $RID" +echo + +# ---------------------------------------------------------------- gestor de paquetes (solo para sugerir) +PM="" +if [ "$OS" = "Darwin" ]; then + PM="brew" +elif command -v apt-get >/dev/null 2>&1; then + PM="apt" +elif command -v dnf >/dev/null 2>&1; then + PM="dnf" +elif command -v pacman >/dev/null 2>&1; then + PM="pacman" +fi + +# ---------------------------------------------------------------- prerrequisitos +missing=() # descripción de lo que falta +pkgs=() # paquetes del gestor del sistema que lo resolverían +manual=() # lo que no se resuelve con el gestor + +if command -v git >/dev/null 2>&1; then + ok "git ($(git --version | awk '{print $3}'))" +else + bad "git"; missing+=("git"); pkgs+=("git") +fi + +dotnet_ok=0 +if command -v dotnet >/dev/null 2>&1; then + dotnet_ver="$(dotnet --version 2>/dev/null || true)" + dotnet_major="${dotnet_ver%%.*}" + if [[ "$dotnet_major" =~ ^[0-9]+$ ]] && [ "$dotnet_major" -ge 10 ]; then + ok "SDK de .NET ($dotnet_ver)" + dotnet_ok=1 + else + bad "SDK de .NET: versión ${dotnet_ver:-desconocida}, se necesita 10 o superior" + fi +else + bad "SDK de .NET 10" +fi +if [ "$dotnet_ok" -eq 0 ]; then + missing+=("SDK de .NET 10") + if [ "$OS" = "Darwin" ]; then + manual+=("brew install --cask dotnet-sdk") + else + manual+=("SDK de .NET 10: https://learn.microsoft.com/dotnet/core/install/linux (o: curl -sSL https://dot.net/v1/dotnet-install.sh | bash -s -- --channel 10.0)") + fi +fi + +if [ "$OS" = "Darwin" ]; then + # En macOS el compilador y el enlazador vienen con las Command Line Tools de Xcode. + if xcode-select -p >/dev/null 2>&1 && command -v clang >/dev/null 2>&1; then + ok "Xcode Command Line Tools" + else + bad "Xcode Command Line Tools"; missing+=("Xcode Command Line Tools"); manual+=("xcode-select --install") + fi +else + if command -v clang >/dev/null 2>&1; then + ok "clang ($(clang --version | head -n1 | sed -E 's/.*version ([0-9.]+).*/\1/'))" + else + bad "clang"; missing+=("clang"); pkgs+=("clang") + fi + + if command -v ld >/dev/null 2>&1; then + ok "enlazador (ld)" + else + bad "enlazador (ld)"; missing+=("binutils") + case "$PM" in + apt) pkgs+=("build-essential") ;; + dnf) pkgs+=("binutils") ;; + pacman) pkgs+=("base-devel") ;; + *) pkgs+=("binutils") ;; + esac + fi + + have_zlib=0 + if [ -f /usr/include/zlib.h ]; then + have_zlib=1 + elif command -v clang >/dev/null 2>&1 \ + && printf '#include \nint main(void){return 0;}\n' | clang -x c -fsyntax-only - >/dev/null 2>&1; then + have_zlib=1 + fi + if [ "$have_zlib" -eq 1 ]; then + ok "cabeceras de zlib" + else + bad "cabeceras de zlib"; missing+=("zlib (cabeceras)") + case "$PM" in + apt) pkgs+=("zlib1g-dev") ;; + dnf) pkgs+=("zlib-devel") ;; + pacman) pkgs+=("zlib") ;; + *) pkgs+=("zlib (cabeceras de desarrollo)") ;; + esac + fi +fi + +if [ "${#missing[@]}" -gt 0 ]; then + echo + printf '%sFaltan herramientas necesarias:%s %s\n' "$Y" "$N" "$(IFS=,; echo "${missing[*]}" | sed 's/,/, /g')" + echo + if [ "${#pkgs[@]}" -gt 0 ]; then + echo "Instálalas con (este script no instala nada por ti):" + echo + case "$PM" in + apt) echo " sudo apt update && sudo apt install -y ${pkgs[*]}" ;; + dnf) echo " sudo dnf install -y ${pkgs[*]}" ;; + pacman) echo " sudo pacman -S --needed ${pkgs[*]}" ;; + brew) echo " brew install ${pkgs[*]}" ;; + *) echo " (con el gestor de paquetes de tu sistema) ${pkgs[*]}" ;; + esac + echo + fi + for m in "${manual[@]}"; do + echo " · $m" + done + echo + echo "Después vuelve a ejecutar este script. Detalles en el README del proyecto." + exit 1 +fi + +# ---------------------------------------------------------------- código fuente +echo +update() { + # Sin remoto (copia local aún sin publicar) no hay nada que actualizar. + if [ -n "$(git -C "$1" remote)" ]; then + git -C "$1" pull --ff-only || die "No se pudo actualizar (¿hay cambios locales?). Resuélvelo y repite." + else + info "El repositorio no tiene remoto configurado: se compila el código tal como está." + fi +} + +if [ -f Sunfish.slnx ] && [ -d .git ]; then + ROOT="$PWD" + info "Usando el repositorio actual: $ROOT" + update "$ROOT" +elif [ -d "$DIR_NAME/.git" ]; then + ROOT="$PWD/$DIR_NAME" + info "Actualizando $ROOT" + update "$ROOT" +else + ROOT="$PWD/$DIR_NAME" + info "Descargando $REPO_URL" + git clone --depth 1 "$REPO_URL" "$ROOT" || die "No se pudo clonar el repositorio." +fi + +# ---------------------------------------------------------------- compilación +export DOTNET_NOLOGO=1 +export DOTNET_CLI_UI_LANGUAGE=en +rm -rf "$ROOT/publish" + +info "Compilando sunfish-cli (NativeAOT, $RID)... puede tardar un minuto" +dotnet publish "$ROOT/src/Sunfish.Cli/Sunfish.Cli.csproj" -c Release -r "$RID" \ + -o "$ROOT/publish/cli" -nologo -v q \ + || die "Falló la compilación de sunfish-cli." +[ -f "$ROOT/publish/cli/sunfish-cli" ] || die "No se encontró el binario sunfish-cli tras compilar." +cp -f "$ROOT/publish/cli/sunfish-cli" "$ROOT/sunfish-cli" +chmod +x "$ROOT/sunfish-cli" +rm -rf "$ROOT/publish" +ok "sunfish-cli → $ROOT/sunfish-cli" + +# ---------------------------------------------------------------- final +echo +echo "${B}${G}Listo.${N} El binario está en: ${B}$ROOT/sunfish-cli${N}" +ls -lh "$ROOT/sunfish-cli" | awk '{printf " %s %s\n", $5, $9}' +echo +echo "${B}Para instalarlo${N} elige una opción (desde ${C}$ROOT${N}):" +echo +echo " ${B}Solo para tu usuario${N} (sin sudo; asegúrate de que ~/.local/bin está en tu PATH):" +echo " mkdir -p ~/.local/bin && install -m 755 sunfish-cli ~/.local/bin/" +echo +echo " ${B}Para todos los usuarios${N}:" +echo " sudo install -m 755 sunfish-cli /usr/local/bin/" +echo +echo "Pruébalo con: sunfish-cli --help (o sunfish-cli CARPETA para compartir una carpeta)" +echo "Los puertos por debajo de 1024 necesitan privilegios en Linux y macOS: el de por defecto es 8080." +echo "Para actualizar a una nueva versión, vuelve a ejecutar este script." +echo diff --git a/run-cli.cmd b/run-cli.cmd new file mode 100644 index 0000000..99c16b8 --- /dev/null +++ b/run-cli.cmd @@ -0,0 +1,5 @@ +@echo off +rem sunfish-cli (TUI de consola) desde el codigo, para desarrollo. Se puede abrir con doble clic. +rem Los argumentos (p. ej. una carpeta arrastrada sobre este fichero) se pasan al programa. +dotnet run --project "%~dp0src\Sunfish.Cli" -- %* +if errorlevel 1 pause diff --git a/run.cmd b/run.cmd new file mode 100644 index 0000000..fc7238d --- /dev/null +++ b/run.cmd @@ -0,0 +1,4 @@ +@echo off +rem Sunfish.exe (ventana) desde el codigo, para desarrollo. Se puede abrir con doble clic. +rem Los argumentos (p. ej. una carpeta arrastrada sobre este fichero) se pasan al programa. +start "" /min /D "%~dp0" dotnet run --project "%~dp0src\Sunfish.WinForms" -- %* diff --git a/run.sh b/run.sh new file mode 100644 index 0000000..43ade63 --- /dev/null +++ b/run.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env sh +# sunfish-cli (TUI de consola) desde el codigo, para desarrollo. Funciona desde cualquier carpeta. +# Los argumentos se pasan al programa (p. ej. ./run.sh ~/Descargas). +cd "$(dirname "$0")" || exit 1 +exec dotnet run --project src/Sunfish.Cli -- "$@" diff --git a/src/Sunfish.Cli/AdminCommands.cs b/src/Sunfish.Cli/AdminCommands.cs new file mode 100644 index 0000000..f9f30c7 --- /dev/null +++ b/src/Sunfish.Cli/AdminCommands.cs @@ -0,0 +1,130 @@ +using Sunfish.Auth; +using Sunfish.Config; +using Sunfish.Net; + +namespace Sunfish.Cli; + +/// +/// sunfish-cli admin status|enable|disable|passwd|allow LISTA: panel de administracion web. +/// sunfish-cli import-v2 [FICHERO]: importa la configuracion de Sunfish 2.0. +/// +internal static class AdminCommands +{ + public static int RunAdmin(string[] args) + { + var (configPath, rest) = CommonOptions(args); + if (rest.Count == 0) + { + Console.Error.WriteLine(Texts.AdminUsage); + return 1; + } + string path = ConfigLocator.Find(configPath) ?? ConfigLocator.PortablePath; + var config = File.Exists(path) ? SunfishConfig.Load(path) : new SunfishConfig { FilePath = path }; + var admin = config.Admin; + + switch (rest[0]) + { + case "status" when rest.Count == 1: + Console.WriteLine(admin.Enabled && admin.PasswordHash.Length > 0 ? Texts.AdminOn : Texts.AdminOffText); + Console.WriteLine(Texts.AdminAllowed(string.Join(", ", admin.AllowFrom))); + if (admin.Enabled) + Console.WriteLine(Strings.AdminUrl($"http://127.0.0.1:{config.Server.HttpPort}/$sunfish/admin/")); + return 0; + + case "enable" when rest.Count == 1: + if (admin.PasswordHash.Length == 0) + { + Console.Error.WriteLine(Texts.AdminNeedsPassword); + if (UserCommands.AskNewPassword() is not { } first) + return 1; + admin.PasswordHash = Passwords.Hash(first); + } + admin.Enabled = true; + config.Save(path); + Console.WriteLine(Strings.AdminUrl($"http://127.0.0.1:{config.Server.HttpPort}/$sunfish/admin/") + " (" + WebUser + ")"); + return 0; + + case "disable" when rest.Count == 1: + admin.Enabled = false; + config.Save(path); + Console.WriteLine(Texts.AdminOffText); + return 0; + + case "passwd" when rest.Count == 1: + if (UserCommands.AskNewPassword() is not { } password) + return 1; + admin.PasswordHash = Passwords.Hash(password); + config.Save(path); + Console.WriteLine(Texts.AdminPasswordSaved(path)); + return 0; + + case "allow" when rest.Count > 1: + var rules = string.Join(' ', rest.Skip(1)).Split([',', ' ', ';'], StringSplitOptions.RemoveEmptyEntries).ToList(); + if (rules.FirstOrDefault(r => !AddressRules.IsValid(r)) is { } bad) + { + Console.Error.WriteLine(Strings.AdminBadAllow(bad)); + return 1; + } + admin.AllowFrom = rules; + config.Save(path); + Console.WriteLine(Texts.AdminAllowed(string.Join(", ", rules))); + return 0; + + default: + Console.Error.WriteLine(Texts.AdminUsage); + return 1; + } + } + + private static string WebUser => Strings.Spanish ? "usuario: admin" : "user: admin"; + + public static int RunImport(string[] args) + { + var (configPath, rest) = CommonOptions(args); + string? source = rest.Count > 0 ? rest[0] : LegacyImport.Find(); + if (source is null || !File.Exists(source)) + { + Console.Error.WriteLine(Texts.ImportNotFound(LegacyImport.FileName)); + return 1; + } + string? found = ConfigLocator.Find(configPath); + bool isNew = found is null || !File.Exists(found); + string path = found ?? configPath ?? ConfigLocator.PortablePath; + return Import(source, path, isNew) ? 0 : 1; + } + + /// Importa en la configuracion de y lo explica. + public static bool Import(string source, string path, bool isNew) + { + var notes = new List(); + var imported = LegacyImport.FromV2(File.ReadAllText(source), notes); + if (imported is null) + { + Console.Error.WriteLine(Strings.ImportInvalid(source)); + return false; + } + var config = isNew ? new SunfishConfig() : SunfishConfig.Load(path); + int count = LegacyImport.Merge(config, imported, isNew); + config.Save(path); + Console.WriteLine(Strings.ImportDone(count, path)); + foreach (var note in notes) + Console.WriteLine(" - " + note); + return true; + } + + private static (string? ConfigPath, List Args) CommonOptions(string[] args) + { + string? configPath = null; + var rest = new List(); + for (int i = 0; i < args.Length; i++) + { + if (args[i] is "-c" or "--config" && i + 1 < args.Length) + configPath = args[++i]; + else if (args[i] == "--lang" && i + 1 < args.Length) + Strings.Force(args[++i]); + else + rest.Add(args[i]); + } + return (configPath, rest); + } +} diff --git a/src/Sunfish.Cli/Program.cs b/src/Sunfish.Cli/Program.cs new file mode 100644 index 0000000..9c5221f --- /dev/null +++ b/src/Sunfish.Cli/Program.cs @@ -0,0 +1,246 @@ +using System.Globalization; +using System.Net.Sockets; +using Sunfish; +using Sunfish.Cli; +using Sunfish.Cli.Tui; +using Sunfish.Config; +using Sunfish.Host; +using Sunfish.Log; +using Sunfish.Net; + +// sunfish-cli: TUI interactiva si hay terminal; si no (o con "serve"), sirve sin interfaz. +// Tambien comparte una carpeta al momento (modo rapido) y gestiona shares y usuarios. + +// Acentos correctos en Windows Terminal, conhost y al redirigir a otros programas. +Console.OutputEncoding = new System.Text.UTF8Encoding(false); + +if (args.Length > 0 && args[0] == "user") + return UserCommands.Run(args[1..]); +if (args.Length > 0 && args[0] == "admin") + return AdminCommands.RunAdmin(args[1..]); +if (args.Length > 0 && args[0] == "import-v2") + return AdminCommands.RunImport(args[1..]); +if (args.Length > 0 && args[0] == "share") + return ShareCommands.Run(args[1..]); + +string? configPath = null; +string? folder = null; +int? port = null; +bool serve = false; +bool verbose = false; + +for (int i = 0; i < args.Length; i++) +{ + string arg = args[i]; + switch (arg) + { + case "-h" or "--help" or "/?": + Console.WriteLine(Texts.Usage); + return 0; + case "--version": + Console.WriteLine("Sunfish 3.0"); + return 0; + case "serve": + serve = true; + break; + case "-v" or "--verbose": + verbose = true; + break; + case "-c" or "--config": + configPath = Value(ref i); + if (configPath is null) return 1; + break; + case "--lang": + string? lang = Value(ref i); + if (lang is null) return 1; + Strings.Force(lang); + break; + case "-p" or "--port": + string? text = Value(ref i); + if (text is null) return 1; + if (!int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out int p) || p > 65535) + { + Console.Error.WriteLine(Texts.BadPort(text)); + return 1; + } + port = p; + break; + default: + if (arg.StartsWith('-') || folder is not null) + { + Console.Error.WriteLine(Texts.UnknownOption(arg)); + return 1; + } + folder = arg; + break; + } +} + +bool interactive = !serve && !Console.IsInputRedirected && !Console.IsOutputRedirected; + +SunfishConfig config; +string? found = ConfigLocator.Find(configPath); +if (folder is not null) +{ + // Modo rapido: la carpeta en "/", con listado y solo lectura. No se guarda en ningun sitio. + string full = Path.GetFullPath(folder); + if (!Directory.Exists(full)) + { + Console.Error.WriteLine(Texts.NotAFolder(full)); + return 1; + } + config = found is not null && File.Exists(found) ? SunfishConfig.Load(found) : new SunfishConfig(); + config.FilePath = null; + config.Shares.Clear(); + config.Shares.Add(new ShareConfig { Name = Path.GetFileName(full.TrimEnd(Path.DirectorySeparatorChar)), Location = "/", Path = full }); + if (!interactive) + Console.WriteLine(Texts.Sharing(full)); +} +else if (found is not null && File.Exists(found)) +{ + config = SunfishConfig.Load(found); +} +else if (interactive && AskWhereToCreate() is { } created) +{ + config = new SunfishConfig(); + config.Save(created); + // Si hay una configuracion de la 2.0 a mano, se ofrece traer sus shares. + if (LegacyImport.Find() is { } legacy) + { + Console.Write(Texts.ImportAsk(legacy)); + var answer = Console.ReadKey(intercept: true); + Console.WriteLine(); + if (answer.Key is ConsoleKey.Enter or ConsoleKey.Y or ConsoleKey.S && AdminCommands.Import(legacy, created, isNew: true)) + config = SunfishConfig.Load(created); + } +} +else +{ + if (!interactive) + { + Console.Error.WriteLine(Texts.NoConfig(ConfigLocator.PortablePath, ConfigLocator.UserPath)); + Console.Error.WriteLine(); + Console.Error.WriteLine(Texts.Usage); + } + return 1; +} + +if (port is int forced) + config.Server.HttpPort = forced; + +var log = new ServerLog { MinimumLevel = verbose ? LogLevel.Detail : LogLevel.Info }; +foreach (var error in config.Errors) + log.Warning(Texts.ConfigWarning(error)); + +await using var host = new SunfishHost(config, log); +if (port is int overridePort) + host.Overrides = c => c.Server.HttpPort = overridePort; +if (!interactive) +{ + log.Written += entry => + { + var writer = entry.Level >= LogLevel.Warning ? Console.Error : Console.Out; + writer.WriteLine($"{entry.Time:HH:mm:ss} {entry.Message}"); + }; + foreach (var entry in log.Recent()) + Console.Error.WriteLine(entry.Message); +} + +try +{ + host.Start(); +} +catch (SocketException e) +{ + // El host ya lo anota en el registro. Sin ningun servicio en marcha, "serve" no tiene sentido; + // en la TUI se puede cambiar el puerto. + if (!interactive && !host.HttpRunning && !host.FtpRunning) + { + Console.Error.WriteLine(Texts.CannotStart(e.Message)); + return 2; + } +} +host.WatchConfigFile(); + +if (interactive) +{ + await new TuiApp(host).RunAsync(); + await host.StopAsync(); + return 0; +} + +PrintUrls(host); +var stop = new TaskCompletionSource(); +Console.CancelKeyPress += (_, e) => +{ + e.Cancel = true; + stop.TrySetResult(); +}; +AppDomain.CurrentDomain.ProcessExit += (_, _) => stop.TrySetResult(); +await stop.Task; +Console.WriteLine(Texts.Stopping); +await host.StopAsync(); +return 0; + +string? Value(ref int i) +{ + if (i + 1 >= args.Length) + { + Console.Error.WriteLine(Texts.MissingValue(args[i])); + return null; + } + return args[++i]; +} + +// Sin configuracion y con terminal (p. ej. doble clic): se ofrece crearla. +static string? AskWhereToCreate() +{ + Console.Write(TuiTexts.NoConfigQuestion(ConfigLocator.PortablePath, ConfigLocator.UserPath)); + while (true) + { + var key = Console.ReadKey(intercept: true); + if (key.KeyChar == '1') + { + Console.WriteLine("1"); + return ConfigLocator.PortablePath; + } + if (key.KeyChar == '2') + { + Console.WriteLine("2"); + return ConfigLocator.UserPath; + } + if (key.Key == ConsoleKey.Escape) + { + Console.WriteLine(); + return null; + } + } +} + +static void PrintUrls(SunfishHost host) +{ + var shares = host.Config.Shares.Where(s => s.Enabled).ToList(); + Console.WriteLine(); + if (shares.Count == 0) + { + Console.WriteLine(Texts.NoShares); + } + else + { + Console.WriteLine(Texts.OpenWith); + var addresses = LocalAddresses.Get(); + foreach (var share in shares) + { + foreach (var address in addresses) + { + if (host.HttpRunning) + Console.WriteLine($" {LocalAddresses.HttpUrl(address.Address, host.HttpPort, share)} ({address.Interface})"); + if (host.FtpRunning && share.Ftp) + Console.WriteLine($" {LocalAddresses.FtpUrl(address.Address, host.FtpPort, share)} ({address.Interface})"); + } + } + } + Console.WriteLine(); + Console.WriteLine(Texts.StopHint); + Console.WriteLine(); +} diff --git a/src/Sunfish.Cli/ShareCommands.cs b/src/Sunfish.Cli/ShareCommands.cs new file mode 100644 index 0000000..7242d8e --- /dev/null +++ b/src/Sunfish.Cli/ShareCommands.cs @@ -0,0 +1,124 @@ +using Sunfish.Config; + +namespace Sunfish.Cli; + +/// sunfish-cli share add|set|del|list: shares del fichero de configuracion. +internal static class ShareCommands +{ + public static int Run(string[] args) + { + string? configPath = null; + var rest = new List(); + for (int i = 0; i < args.Length; i++) + { + if (args[i] is "-c" or "--config" && i + 1 < args.Length) + configPath = args[++i]; + else if (args[i] == "--lang" && i + 1 < args.Length) + Strings.Force(args[++i]); + else + rest.Add(args[i]); + } + if (rest.Count == 0) + return Usage(); + + string path = ConfigLocator.Find(configPath) ?? ConfigLocator.PortablePath; + var config = File.Exists(path) ? SunfishConfig.Load(path) : new SunfishConfig { FilePath = path }; + string verb = rest[0]; + + if (verb == "list") + { + if (config.Shares.Count == 0) + Console.WriteLine(Strings.AppNoShares); + foreach (var s in config.Shares) + { + string flags = string.Join(' ', new[] + { + s.Enabled ? "" : "(off)", s.Listing ? "listing" : "", s.Upload ? "upload" : "", s.Overwrite ? "overwrite" : "", + s.Rename ? "rename" : "", s.Delete ? "delete" : "", s.Zip ? "zip" : "", + s.Users.Count > 0 ? "users=" + string.Join(',', s.Users) : "", + }.Where(f => f.Length > 0)); + Console.WriteLine($"{s.Name} {s.Location} {s.Path} {flags}"); + } + return 0; + } + if (rest.Count < 2) + return Usage(); + + string name = rest[1]; + var share = config.Shares.FirstOrDefault(s => s.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + switch (verb) + { + case "add": + { + if (rest.Count < 3) + return Usage(); + if (!ShareSettings.IsValidName(name)) + return Fail(Strings.ErrorBadName(name)); + if (share is not null) + return Fail(Strings.ErrorNameInUse(name)); + string folder = Path.GetFullPath(rest[2]); + if (!Directory.Exists(folder)) + return Fail(Strings.ErrorNotAFolder(folder)); + share = new ShareConfig { Name = name, Path = folder, Location = ShareSettings.SuggestLocation(config, name) }; + if (ApplySettings(share, rest.Skip(3)) is { } error) + return Fail(error); + if (config.Shares.Any(s => s.Enabled && s.Location.Equals(share.Location, StringComparison.OrdinalIgnoreCase))) + return Fail(Strings.ErrorNameInUse(share.Location)); + config.Shares.Add(share); + break; + } + case "set": + { + if (share is null) + return Fail(Texts.ShareMissing(name)); + if (ApplySettings(share, rest.Skip(2)) is { } error) + return Fail(error); + break; + } + case "del": + if (share is null) + return Fail(Texts.ShareMissing(name)); + config.Shares.Remove(share); + break; + default: + return Usage(); + } + config.Save(path); + Console.WriteLine(Texts.ShareSaved(name, path)); + return 0; + } + + /// Opciones "Clave=valor" (las del INI) o "--location /x". + private static string? ApplySettings(ShareConfig share, IEnumerable settings) + { + var list = settings.ToList(); + for (int i = 0; i < list.Count; i++) + { + string item = list[i]; + if (item == "--location" && i + 1 < list.Count) + { + if (ShareSettings.TrySet(share, "Location", list[++i]) is { } bad) + return bad; + continue; + } + int eq = item.IndexOf('='); + if (eq <= 0) + return Strings.UnknownShareKey(item); + if (ShareSettings.TrySet(share, item[..eq], item[(eq + 1)..]) is { } error) + return error; + } + return null; + } + + private static int Usage() + { + Console.Error.WriteLine(Texts.ShareUsage); + return 1; + } + + private static int Fail(string message) + { + Console.Error.WriteLine(message); + return 1; + } +} diff --git a/src/Sunfish.Cli/Sunfish.Cli.csproj b/src/Sunfish.Cli/Sunfish.Cli.csproj new file mode 100644 index 0000000..99edc15 --- /dev/null +++ b/src/Sunfish.Cli/Sunfish.Cli.csproj @@ -0,0 +1,26 @@ + + + + Exe + net10.0 + enable + enable + sunfish-cli + Sunfish.Cli + 3.0.0 + XWolfOverride + true + ..\..\art\sunfish.ico + + + true + true + true + true + + + + + + + diff --git a/src/Sunfish.Cli/Texts.cs b/src/Sunfish.Cli/Texts.cs new file mode 100644 index 0000000..53df9be --- /dev/null +++ b/src/Sunfish.Cli/Texts.cs @@ -0,0 +1,108 @@ +namespace Sunfish.Cli; + +/// Textos propios de la consola (el idioma lo decide Sunfish.Strings). +internal static class Texts +{ + private static string T(string english, string spanish) => Strings.Spanish ? spanish : english; + + public static string Usage => T( + """ + Sunfish 3.0 - web and file server for old and new computers + + Usage: + sunfish-cli [options] interactive screen (with a terminal); otherwise like serve + sunfish-cli FOLDER [options] share a folder right now (read only, not saved) + sunfish-cli serve [options] serve without interface (services, scripts) + sunfish-cli share add NAME FOLDER [Key=value ...] + sunfish-cli share set NAME Key=value ... (keys of the [Share] section, e.g. Upload=true) + sunfish-cli share del NAME | share list + sunfish-cli user add|passwd|del NAME | user list + sunfish-cli admin status|enable|disable|passwd|allow ... (web administration panel) + sunfish-cli import-v2 [FILE] import a Sunfish 2.0 configuration (sunfish2) + sunfish-cli --help | --version + + Options: + -c, --config FILE configuration file (default: sunfish.conf next to the + program, or the user configuration folder) + -p, --port N HTTP port (default 8080) + --lang es|en language of the messages + -v, --verbose log every connection detail + """, + """ + Sunfish 3.0 - servidor web y de ficheros para equipos antiguos y modernos + + Uso: + sunfish-cli [opciones] pantalla interactiva (con terminal); si no, como serve + sunfish-cli CARPETA [opciones] comparte una carpeta al momento (solo lectura, no se guarda) + sunfish-cli serve [opciones] sirve sin interfaz (servicios, scripts) + sunfish-cli share add NOMBRE CARPETA [Clave=valor ...] + sunfish-cli share set NOMBRE Clave=valor ... (claves de la seccion [Share], p. ej. Upload=true) + sunfish-cli share del NOMBRE | share list + sunfish-cli user add|passwd|del NOMBRE | user list + sunfish-cli admin status|enable|disable|passwd|allow ... (panel de administración web) + sunfish-cli import-v2 [FICHERO] importa la configuración de Sunfish 2.0 (sunfish2) + sunfish-cli --help | --version + + Opciones: + -c, --config FICHERO fichero de configuracion (por defecto: sunfish.conf junto al + programa, o la carpeta de configuracion del usuario) + -p, --port N puerto HTTP (por defecto 8080) + --lang es|en idioma de los mensajes + -v, --verbose registrar todos los detalles de las conexiones + """); + + public static string NoConfig(string portable, string user) => T( + $"No configuration file found. Create {portable} or {user}, or share a folder with: sunfish-cli FOLDER", + $"No hay fichero de configuracion. Crea {portable} o {user}, o comparte una carpeta con: sunfish-cli CARPETA"); + + public static string NotAFolder(string path) => T($"Not a folder: {path}", $"No es una carpeta: {path}"); + public static string UnknownOption(string option) => T($"Unknown option: {option}", $"Opcion desconocida: {option}"); + public static string MissingValue(string option) => T($"Missing value for {option}", $"Falta el valor de {option}"); + public static string BadPort(string value) => T($"Invalid port: {value}", $"Puerto no valido: {value}"); + public static string ConfigWarning(string message) => T($"Configuration: {message}", $"Configuracion: {message}"); + public static string CannotStart(string message) => T($"Cannot start the server: {message}", $"No se puede arrancar el servidor: {message}"); + public static string Sharing(string path) => T($"Sharing {path}", $"Compartiendo {path}"); + public static string OpenWith => T("Open in a browser:", "Abre en un navegador:"); + public static string NoShares => T("There are no enabled shares.", "No hay shares activos."); + public static string StopHint => T("Press Ctrl+C to stop.", "Pulsa Ctrl+C para parar."); + public static string Stopping => T("Stopping...", "Parando..."); + + public static string UserUsage => T( + "Usage: sunfish-cli user add|passwd|del NAME [-c FILE] | sunfish-cli user list [-c FILE]", + "Uso: sunfish-cli user add|passwd|del NOMBRE [-c FICHERO] | sunfish-cli user list [-c FICHERO]"); + public static string PasswordPrompt => T("Password: ", "Contraseña: "); + public static string PasswordRepeat => T("Repeat the password: ", "Repite la contraseña: "); + public static string PasswordMismatch => T("The passwords do not match.", "Las contraseñas no coinciden."); + public static string PasswordEmpty => T("The password cannot be empty.", "La contraseña no puede estar vacía."); + public static string UserExists(string name) => T($"The user '{name}' already exists.", $"El usuario '{name}' ya existe."); + public static string UserMissing(string name) => T($"There is no user '{name}'.", $"No existe el usuario '{name}'."); + public static string BadUserName(string name) => T($"Invalid user name: '{name}'.", $"Nombre de usuario no válido: '{name}'."); + public static string UserSaved(string name, string file) => T($"User '{name}' saved in {file}", $"Usuario '{name}' guardado en {file}"); + public static string UserDeleted(string name) => T($"User '{name}' deleted.", $"Usuario '{name}' borrado."); + public static string NoUsers => T("There are no users.", "No hay usuarios."); + public static string UserShares(string shares) => T($"shares: {shares}", $"shares: {shares}"); + public static string ShareUsage => T( + "Usage: sunfish-cli share add NAME FOLDER [Key=value ...] | share set NAME Key=value ... | share del NAME | share list [-c FILE]", + "Uso: sunfish-cli share add NOMBRE CARPETA [Clave=valor ...] | share set NOMBRE Clave=valor ... | share del NOMBRE | share list [-c FICHERO]"); + public static string ShareMissing(string name) => T($"There is no share '{name}'.", $"No existe el share '{name}'."); + public static string ShareSaved(string name, string file) => T($"Share '{name}' saved in {file}", $"Share '{name}' guardado en {file}"); + public static string AdminUsage => T( + "Usage: sunfish-cli admin status | enable | disable | passwd | allow ADDRESSES [-c FILE]\n" + + " ADDRESSES: IPs or networks, e.g. 127.0.0.1,::1,192.168.1.0/24 (or *)", + "Uso: sunfish-cli admin status | enable | disable | passwd | allow DIRECCIONES [-c FICHERO]\n" + + " DIRECCIONES: IP o redes, p. ej. 127.0.0.1,::1,192.168.1.0/24 (o *)"); + public static string AdminOn => T("Web administration panel: enabled", "Panel de administración web: activado"); + public static string AdminOffText => T("Web administration panel: disabled", "Panel de administración web: desactivado"); + public static string AdminAllowed(string list) => T($"Allowed from: {list}", $"Permitido desde: {list}"); + public static string AdminNeedsPassword => T("Choose a password for the panel (user: admin).", "Elige una contraseña para el panel (usuario: admin)."); + public static string AdminPasswordSaved(string file) => T($"Panel password saved in {file}", $"Contraseña del panel guardada en {file}"); + public static string ImportNotFound(string name) => T( + $"No '{name}' file found next to the program or in the current folder. Usage: sunfish-cli import-v2 [FILE] [-c FILE]", + $"No hay un fichero '{name}' junto al programa ni en la carpeta actual. Uso: sunfish-cli import-v2 [FICHERO] [-c FICHERO]"); + public static string ImportAsk(string file) => T( + $"Sunfish 2.0 configuration found ({file}). Import its shares? [Y/n] ", + $"Hay una configuración de Sunfish 2.0 ({file}). ¿Importar sus shares? [S/n] "); + public static string GrantHint => T( + "To protect a share, add its name to 'Users = ...' in the share section.", + "Para proteger un share, añade el nombre a 'Users = ...' en su sección."); +} diff --git a/src/Sunfish.Cli/Tui/Terminal.cs b/src/Sunfish.Cli/Tui/Terminal.cs new file mode 100644 index 0000000..8403c7d --- /dev/null +++ b/src/Sunfish.Cli/Tui/Terminal.cs @@ -0,0 +1,150 @@ +using System.Runtime.InteropServices; +using System.Text; + +namespace Sunfish.Cli.Tui; + +/// Terminal ANSI/VT: pantalla alternativa, colores y tamano. Sin bibliotecas externas. +internal static partial class Terminal +{ + public const string Reset = "\e[0m"; + public const string Bold = "\e[1m"; + public const string Dim = "\e[2m"; + public const string Reverse = "\e[7m"; + + /// El terminal acepta color de 24 bits (Windows Terminal lo anuncia con WT_SESSION). + public static bool TrueColor { get; } = + (Environment.GetEnvironmentVariable("COLORTERM") ?? "") is "truecolor" or "24bit" || + Environment.GetEnvironmentVariable("WT_SESSION") is not null; + + public static int Width => Math.Max(40, SafeSize(() => Console.WindowWidth, 80)); + public static int Height => Math.Max(12, SafeSize(() => Console.WindowHeight, 25)); + + private static int SafeSize(Func get, int fallback) + { + try + { + int value = get(); + return value > 0 ? value : fallback; + } + catch (IOException) + { + return fallback; + } + } + + public static void Enter() + { + EnableVirtualTerminal(); + Console.Write("\e[?1049h\e[?25l\e[H\e[2J"); + } + + public static void Leave() => Console.Write("\e[0m\e[?25h\e[?1049l"); + + /// Escribe el fotograma entero de una vez (sin parpadeo). + public static void Draw(string frame) => Console.Write(frame); + + public static string Fg(int r, int g, int b) => TrueColor ? $"\e[38;2;{r};{g};{b}m" : $"\e[38;5;{To256(r, g, b)}m"; + public static string Bg(int r, int g, int b) => TrueColor ? $"\e[48;2;{r};{g};{b}m" : $"\e[48;5;{To256(r, g, b)}m"; + + public static string Fg16(int code) => $"\e[{code}m"; + + /// Color mas cercano del cubo 6x6x6 de la paleta de 256 colores. + internal static int To256(int r, int g, int b) + { + static int Level(int v) => v < 48 ? 0 : v < 115 ? 1 : (v - 35) / 40; + return 16 + 36 * Level(r) + 6 * Level(g) + Level(b); + } + + /// + /// Como pero con texto que ya lleva secuencias de color: solo cuentan los + /// caracteres visibles y las secuencias se conservan. + /// + public static string FitVisible(string text, int width) + { + var sb = new StringBuilder(); + int visible = 0; + for (int i = 0; i < text.Length; i++) + { + char c = text[i]; + if (c == '\e') + { + int end = i + 1; + if (end < text.Length && text[end] == '[') + { + end++; + while (end < text.Length && !char.IsAsciiLetter(text[end])) + end++; + } + sb.Append(text, i, Math.Min(end, text.Length - 1) - i + 1); + i = end; + continue; + } + if (visible == width) + break; + if (visible == width - 1 && HasMoreVisible(text, i + 1)) + { + sb.Append('…'); + visible++; + break; + } + sb.Append(char.IsControl(c) ? '?' : c); + visible++; + } + if (visible < width) + sb.Append(Reset).Append(' ', width - visible); + return sb.ToString(); + } + + private static bool HasMoreVisible(string text, int from) + { + for (int i = from; i < text.Length; i++) + { + if (text[i] == '\e') + { + i++; + if (i < text.Length && text[i] == '[') + while (i < text.Length && !char.IsAsciiLetter(text[i])) + i++; + continue; + } + return true; + } + return false; + } + + /// Texto recortado o rellenado a un ancho exacto (contando caracteres). + public static string Fit(string text, int width) + { + if (width <= 0) + return ""; + var sb = new StringBuilder(); + foreach (char c in text) + sb.Append(char.IsControl(c) ? '?' : c); + string clean = sb.ToString(); + if (clean.Length > width) + return width > 1 ? clean[..(width - 1)] + "…" : clean[..width]; + return clean.PadRight(width); + } + + private static void EnableVirtualTerminal() + { + if (!OperatingSystem.IsWindows()) + return; + const int StdOutputHandle = -11; + const uint EnableVirtualTerminalProcessing = 0x0004; + nint handle = GetStdHandle(StdOutputHandle); + if (GetConsoleMode(handle, out uint mode)) + SetConsoleMode(handle, mode | EnableVirtualTerminalProcessing); + } + + [LibraryImport("kernel32.dll")] + private static partial nint GetStdHandle(int nStdHandle); + + [LibraryImport("kernel32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool GetConsoleMode(nint hConsoleHandle, out uint lpMode); + + [LibraryImport("kernel32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool SetConsoleMode(nint hConsoleHandle, uint dwMode); +} diff --git a/src/Sunfish.Cli/Tui/TuiApp.cs b/src/Sunfish.Cli/Tui/TuiApp.cs new file mode 100644 index 0000000..12683d2 --- /dev/null +++ b/src/Sunfish.Cli/Tui/TuiApp.cs @@ -0,0 +1,833 @@ +using System.Net.Sockets; +using System.Text; +using System.Threading.Channels; +using Sunfish.Auth; +using Sunfish.Config; +using Sunfish.Host; +using Sunfish.Log; +using Sunfish.Net; + +namespace Sunfish.Cli.Tui; + +/// +/// Interfaz interactiva de sunfish-cli: sirve y gestiona a la vez. Se redibuja la pantalla entera +/// en cada cambio (tecla, registro, estado). Las ventanas modales (preguntas, formularios) se +/// apilan sobre las pestanas. +/// +internal sealed partial class TuiApp +{ + private enum Tab { Status, Shares, Users, Explore, Log } + private const int TabCount = 5; + + private abstract class Overlay; + + private sealed class InputOverlay(string prompt, string text, bool mask, Func accept) : Overlay + { + public string Prompt { get; } = prompt; + public StringBuilder Text { get; } = new(text); + public bool Mask { get; } = mask; + public Func Accept { get; } = accept; + } + + private sealed class ConfirmOverlay(string question, Func yes) : Overlay + { + public string Question { get; } = question; + public Func Yes { get; } = yes; + } + + private sealed class MessageOverlay(string text) : Overlay + { + public string Text { get; } = text; + } + + /// Codigo QR de una direccion; las flechas pasan a la siguiente. + private sealed class QrOverlay(List urls) : Overlay + { + public List Urls { get; } = urls; + public int Index { get; set; } + } + + private sealed class ShareForm(ShareConfig share, string? originalName) : Overlay + { + public ShareConfig Share { get; } = share; + public string? OriginalName { get; } = originalName; + public int Selected { get; set; } + } + + private readonly SunfishHost _host; + private readonly Channel _events = Channel.CreateUnbounded(); + private readonly List _overlays = new(); + private Tab _tab; + private int _shareSel; + private int _userSel; + private bool _quit; + private string? _status; + private string[]? _icon; + + public TuiApp(SunfishHost host) => _host = host; + + private bool IsQuick => _host.Config.FilePath is null; + + public async Task RunAsync() + { + Console.TreatControlCAsInput = true; + Terminal.Enter(); + _host.Log.Written += _ => _events.Writer.TryWrite(null); + _host.Changed += () => _events.Writer.TryWrite(null); + var keys = new Thread(ReadKeys) { IsBackground = true, Name = "tui-keys" }; + keys.Start(); + _ = WatchResizeAsync(); + try + { + while (!_quit) + { + Render(); + var ev = await _events.Reader.ReadAsync(); + await HandleAsync(ev); + while (!_quit && _events.Reader.TryRead(out ev)) + await HandleAsync(ev); + } + } + finally + { + Terminal.Leave(); + Console.TreatControlCAsInput = false; + } + } + + private void ReadKeys() + { + while (!_quit) + { + try + { + _events.Writer.TryWrite(Console.ReadKey(intercept: true)); + } + catch (InvalidOperationException) + { + return; + } + } + } + + private async Task WatchResizeAsync() + { + int w = Terminal.Width, h = Terminal.Height; + while (!_quit) + { + await Task.Delay(400); + if (Terminal.Width != w || Terminal.Height != h) + { + w = Terminal.Width; + h = Terminal.Height; + _events.Writer.TryWrite(null); + } + } + } + + // ------------------------------------------------------------------ teclas + + private async Task HandleAsync(ConsoleKeyInfo? ev) + { + if (ev is not { } key) + return; // solo redibujar + _status = null; + if (_overlays.Count > 0) + { + await HandleOverlayAsync(_overlays[^1], key); + return; + } + if (key.Key == ConsoleKey.Tab) + { + _tab = (Tab)(((int)_tab + (key.Modifiers.HasFlag(ConsoleModifiers.Shift) ? TabCount - 1 : 1)) % TabCount); + return; + } + if (key.Key is >= ConsoleKey.F1 and <= ConsoleKey.F5) + { + _tab = (Tab)(key.Key - ConsoleKey.F1); + return; + } + if (_tab == Tab.Explore && await ExploreKeyAsync(key)) + return; + if (key.Key is ConsoleKey.Escape or ConsoleKey.Q || (key.Key == ConsoleKey.C && key.Modifiers.HasFlag(ConsoleModifiers.Control))) + { + _overlays.Add(new ConfirmOverlay(TuiTexts.QuitQuestion, () => + { + _quit = true; + return Task.CompletedTask; + })); + return; + } + switch (_tab) + { + case Tab.Status: await StatusKeyAsync(key); break; + case Tab.Shares: await SharesKeyAsync(key); break; + case Tab.Users: UsersKey(key); break; + } + } + + private async Task StatusKeyAsync(ConsoleKeyInfo key) + { + switch (key.Key) + { + case ConsoleKey.S: + if (_host.Running) + await _host.StopAsync(); + else + StartServer(); + break; + case ConsoleKey.P: + AskPort(TuiTexts.PortPrompt, _host.Config.Server.HttpPort, (c, port) => c.Server.HttpPort = port); + break; + case ConsoleKey.T: + AskPort(TuiTexts.FtpPortPrompt, _host.Config.Server.FtpPort, (c, port) => c.Server.FtpPort = port); + break; + case ConsoleKey.F: + await ChangeAsync(c => c.Server.Ftp = !c.Server.Ftp); + break; + case ConsoleKey.C: + var urls = QrUrls(); + _overlays.Add(urls.Count > 0 ? new QrOverlay(urls) : new MessageOverlay(TuiTexts.NoQr)); + break; + case ConsoleKey.N: + _overlays.Add(new InputOverlay(TuiTexts.NamePrompt, _host.Config.Server.Name, false, async text => + { + if (text.Trim().Length > 0) + await ChangeAsync(c => c.Server.Name = text.Trim()); + })); + break; + } + } + + private void AskPort(string prompt, int current, Action set) + { + _overlays.Add(new InputOverlay(prompt, current.ToString(), false, async text => + { + if (!int.TryParse(text, out int port) || port is < 1 or > 65535) + { + _overlays.Add(new MessageOverlay(TuiTexts.BadPort)); + return; + } + await ChangeAsync(c => set(c, port)); + })); + } + + private void StartServer() + { + try + { + _host.Start(); + } + catch (SocketException e) + { + _overlays.Add(new MessageOverlay(Strings.CannotListen(_host.Config.Server.HttpPort, e.Message))); + } + } + + private async Task SharesKeyAsync(ConsoleKeyInfo key) + { + var shares = _host.Config.Shares; + switch (key.Key) + { + case ConsoleKey.UpArrow: _shareSel = Math.Max(0, _shareSel - 1); break; + case ConsoleKey.DownArrow: _shareSel = Math.Min(Math.Max(0, shares.Count - 1), _shareSel + 1); break; + case ConsoleKey.A or ConsoleKey.Insert: + _overlays.Add(new InputOverlay(TuiTexts.NewShareFolder, Environment.CurrentDirectory, false, text => + { + string path = text.Trim().Trim('"'); + if (!Directory.Exists(path)) + { + _overlays.Add(new MessageOverlay(Strings.ErrorNotAFolder(path))); + return Task.CompletedTask; + } + path = Path.GetFullPath(path); + string folderName = Path.GetFileName(Path.TrimEndingDirectorySeparator(path)); + var share = new ShareConfig + { + Name = ShareSettings.SuggestName(_host.Config, folderName), + Location = ShareSettings.SuggestLocation(_host.Config, folderName), + Path = path, + }; + _overlays.Add(new ShareForm(share, null)); + return Task.CompletedTask; + })); + break; + case ConsoleKey.Enter or ConsoleKey.E when shares.Count > 0: + _overlays.Add(new ShareForm(shares[_shareSel].Clone(), shares[_shareSel].Name)); + break; + case ConsoleKey.Spacebar when shares.Count > 0: + string toggled = shares[_shareSel].Name; + await ChangeAsync(c => c.Shares.First(s => s.Name == toggled).Enabled ^= true); + break; + case ConsoleKey.D or ConsoleKey.Delete when shares.Count > 0: + string removed = shares[_shareSel].Name; + _overlays.Add(new ConfirmOverlay(Strings.AskDeleteShare(removed), async () => + { + await ChangeAsync(c => c.Shares.RemoveAll(s => s.Name == removed)); + _shareSel = Math.Max(0, _shareSel - 1); + })); + break; + } + } + + private void UsersKey(ConsoleKeyInfo key) + { + var users = _host.Config.Users; + switch (key.Key) + { + case ConsoleKey.UpArrow: _userSel = Math.Max(0, _userSel - 1); break; + case ConsoleKey.DownArrow: _userSel = Math.Min(Math.Max(0, users.Count - 1), _userSel + 1); break; + case ConsoleKey.A or ConsoleKey.Insert: + _overlays.Add(new InputOverlay(TuiTexts.NewUserName, "", false, name => + { + name = name.Trim(); + if (!ShareSettings.IsValidName(name)) + _overlays.Add(new MessageOverlay(Strings.ErrorBadName(name))); + else if (_host.Config.Users.Any(u => u.Name.Equals(name, StringComparison.OrdinalIgnoreCase))) + _overlays.Add(new MessageOverlay(Strings.ErrorNameInUse(name))); + else + AskPassword(name, isNew: true); + return Task.CompletedTask; + })); + break; + case ConsoleKey.P when users.Count > 0: + AskPassword(users[_userSel].Name, isNew: false); + break; + case ConsoleKey.D or ConsoleKey.Delete when users.Count > 0: + string removed = users[_userSel].Name; + _overlays.Add(new ConfirmOverlay(Strings.AskDeleteUser(removed), async () => + { + await ChangeAsync(c => + { + c.Users.RemoveAll(u => u.Name == removed); + foreach (var s in c.Shares) + s.Users.RemoveAll(u => u.Equals(removed, StringComparison.OrdinalIgnoreCase)); + }); + _userSel = Math.Max(0, _userSel - 1); + })); + break; + } + } + + private void AskPassword(string name, bool isNew) + { + _overlays.Add(new InputOverlay(TuiTexts.NewPassword(name), "", true, first => + { + if (first.Length == 0) + { + _overlays.Add(new MessageOverlay(Strings.ErrorPasswordEmpty)); + return Task.CompletedTask; + } + _overlays.Add(new InputOverlay(TuiTexts.RepeatPassword, "", true, async second => + { + if (second != first) + { + _overlays.Add(new MessageOverlay(TuiTexts.PasswordMismatch)); + return; + } + string hash = Passwords.Hash(first); + await ChangeAsync(c => + { + if (isNew) + c.Users.Add(new UserConfig { Name = name, PasswordHash = hash }); + else + c.Users.First(u => u.Name == name).PasswordHash = hash; + }); + })); + return Task.CompletedTask; + })); + } + + private async Task HandleOverlayAsync(Overlay overlay, ConsoleKeyInfo key) + { + switch (overlay) + { + case MessageOverlay: + _overlays.Remove(overlay); + break; + + case ConfirmOverlay confirm: + _overlays.Remove(overlay); + if (TuiTexts.IsYes(key.KeyChar)) + await confirm.Yes(); + break; + + case InputOverlay input: + if (key.Key == ConsoleKey.Escape) + _overlays.Remove(overlay); + else if (key.Key == ConsoleKey.Enter) + { + _overlays.Remove(overlay); + await input.Accept(input.Text.ToString()); + } + else if (key.Key == ConsoleKey.Backspace) + { + if (input.Text.Length > 0) + input.Text.Length--; + } + else if (!char.IsControl(key.KeyChar)) + input.Text.Append(key.KeyChar); + break; + + case ShareForm form: + await HandleFormAsync(form, key); + break; + + case QrOverlay qr: + if (key.Key is ConsoleKey.RightArrow or ConsoleKey.DownArrow or ConsoleKey.Tab or ConsoleKey.Spacebar) + qr.Index = (qr.Index + 1) % qr.Urls.Count; + else if (key.Key is ConsoleKey.LeftArrow or ConsoleKey.UpArrow) + qr.Index = (qr.Index + qr.Urls.Count - 1) % qr.Urls.Count; + else + _overlays.Remove(overlay); + break; + } + } + + /// Direcciones para el QR: cada share activo en cada interfaz (HTTP y, si esta, FTP). + private List QrUrls() + { + var urls = new List(); + var addresses = LocalAddresses.Get(); + foreach (var share in _host.Config.Shares.Where(s => s.Enabled)) + foreach (var a in addresses) + { + if (_host.HttpRunning) + urls.Add(LocalAddresses.HttpUrl(a.Address, _host.HttpPort, share)); + if (_host.FtpRunning && share.Ftp) + urls.Add(LocalAddresses.FtpUrl(a.Address, _host.FtpPort, share)); + } + return urls; + } + + /// + /// El QR ocupa el cuerpo: medios bloques negros sobre fondo blanco (con el fondo oscuro del + /// terminal los moviles no lo leen), centrado, con la direccion debajo. + /// + private static List QrBody(QrOverlay qr, int width, int height) + { + string url = qr.Urls[qr.Index]; + var code = Qr.QrImage.HalfBlocks(Qr.QrCode.Encode(url), 2).ToList(); + var lines = new List(); + int top = Math.Max(0, (height - code.Count - 2) / 2); + for (int i = 0; i < top; i++) + lines.Add(""); + if (code.Count + 2 > height || code[0].Length > width) + { + lines.Add(TuiTexts.QrTooSmall); + lines.Add(url); + return lines; + } + string pad = new string(' ', Math.Max(0, (width - code[0].Length) / 2)); + foreach (var row in code) + lines.Add(pad + Terminal.Fg(0, 0, 0) + Terminal.Bg(255, 255, 255) + row + Terminal.Reset); + string caption = $"{url} ({qr.Index + 1}/{qr.Urls.Count})"; + lines.Add(new string(' ', Math.Max(0, (width - caption.Length) / 2)) + Terminal.Bold + caption + Terminal.Reset); + return lines; + } + + // ------------------------------------------------------------------ formulario de share + + private static readonly string[] TextFields = ["Name", "Location", "Path", "Index", "Users"]; + private static readonly string[] BoolFields = ["Enabled", "Listing", "Upload", "Overwrite", "Rename", "Delete", "Zip"]; + private static int FormRows => TextFields.Length + BoolFields.Length + 2; + + private async Task HandleFormAsync(ShareForm form, ConsoleKeyInfo key) + { + switch (key.Key) + { + case ConsoleKey.Escape: + _overlays.Remove(form); + return; + case ConsoleKey.UpArrow: + form.Selected = (form.Selected + FormRows - 1) % FormRows; + return; + case ConsoleKey.DownArrow or ConsoleKey.Tab: + form.Selected = (form.Selected + 1) % FormRows; + return; + case ConsoleKey.Enter or ConsoleKey.Spacebar: + break; + default: + return; + } + + int i = form.Selected; + var s = form.Share; + if (i < TextFields.Length) + { + if (key.Key != ConsoleKey.Enter) + return; + string field = TextFields[i]; + _overlays.Add(new InputOverlay(FieldLabel(field), FieldValue(s, field), false, text => + { + text = text.Trim(); + if (field == "Name") + { + if (!ShareSettings.IsValidName(text)) + _overlays.Add(new MessageOverlay(Strings.ErrorBadName(text))); + else + s.Name = text; + } + else if (ShareSettings.TrySet(s, field, text) is { } error) + { + _overlays.Add(new MessageOverlay(error)); + } + return Task.CompletedTask; + })); + return; + } + i -= TextFields.Length; + if (i < BoolFields.Length) + { + ShareSettings.TrySet(s, BoolFields[i], (!FieldBool(s, BoolFields[i])).ToString()); + return; + } + i -= BoolFields.Length; + if (i == 1 || key.Key != ConsoleKey.Enter) + { + if (i == 1) + _overlays.Remove(form); + return; + } + + // Guardar + if (ValidateShare(form) is { } problem) + { + _overlays.Add(new MessageOverlay(problem)); + return; + } + _overlays.Remove(form); + await ChangeAsync(c => + { + int index = form.OriginalName is null ? -1 : c.Shares.FindIndex(x => x.Name == form.OriginalName); + if (index >= 0) + c.Shares[index] = s; + else + c.Shares.Add(s); + }); + _shareSel = Math.Max(0, _host.Config.Shares.FindIndex(x => x.Name == s.Name)); + } + + private string? ValidateShare(ShareForm form) => + ShareSettings.Validate(_host.Config, form.Share, form.OriginalName); + + private static string FieldLabel(string field) => field switch + { + "Name" => Strings.FieldName, + "Location" => Strings.FieldLocation, + "Path" => Strings.FieldPath, + "Index" => Strings.FieldIndex, + "Users" => Strings.FieldUsers, + "Enabled" => Strings.OptEnabled, + "Listing" => Strings.OptListing, + "Upload" => Strings.OptUpload, + "Overwrite" => Strings.OptOverwrite, + "Rename" => Strings.OptRename, + "Delete" => Strings.OptDelete, + "Zip" => Strings.OptZip, + _ => field, + }; + + private static string FieldValue(ShareConfig s, string field) => field switch + { + "Name" => s.Name, + "Location" => s.Location, + "Path" => s.Path, + "Index" => s.Index, + "Users" => string.Join(", ", s.Users), + _ => "", + }; + + private static bool FieldBool(ShareConfig s, string field) => field switch + { + "Enabled" => s.Enabled, + "Listing" => s.Listing, + "Upload" => s.Upload, + "Overwrite" => s.Overwrite, + "Rename" => s.Rename, + "Delete" => s.Delete, + "Zip" => s.Zip, + _ => false, + }; + + /// Cambia una copia de la configuracion, la guarda (si hay fichero) y la aplica. + private async Task ChangeAsync(Action change) + { + var copy = _host.Config.Clone(); + change(copy); + bool ok = IsQuick ? await _host.ApplyAsync(copy) : await _host.SaveAndApplyAsync(copy); + _status = IsQuick ? TuiTexts.NotSavedQuick : ok ? TuiTexts.Saved : null; + } + + // ------------------------------------------------------------------ dibujo + // La TUI solo se usa en terminales modernos: marco de caja con esquinas redondeadas, pestanas + // en el borde superior y atajos en el inferior. (La web es la que se mantiene compatible con + // equipos antiguos.) + + private static readonly string Frame = Terminal.Fg(90, 140, 190); + private static readonly string Accent = Terminal.Fg(120, 200, 255); + private static readonly string Muted = Terminal.Fg(130, 130, 140); + + private void Render() + { + int width = Terminal.Width; + int height = Terminal.Height; + int inner = width - 4; // "│ " + contenido + " │" + int bodyHeight = height - 4; // borde superior, separador, estado, borde inferior + + var body = _tab switch + { + Tab.Status => StatusBody(inner, bodyHeight), + Tab.Shares => SharesBody(inner), + Tab.Users => UsersBody(inner), + Tab.Explore => ExploreBody(inner, bodyHeight), + _ => LogBody(inner, bodyHeight), + }; + if (_overlays.Count > 0 && _overlays[^1] is QrOverlay qr) + body = QrBody(qr, inner, bodyHeight); + var lines = body.Take(bodyHeight).ToList(); + while (lines.Count < bodyHeight) + lines.Add(""); + if (_overlays.Count > 0 && _overlays[^1] is not QrOverlay) + DrawOverlay(lines, _overlays[^1], inner, bodyHeight); + + string hint = _overlays.Count > 0 ? OverlayHint(_overlays[^1]) : _tab switch + { + Tab.Status => TuiTexts.KeysStatus, + Tab.Shares => TuiTexts.KeysShares, + Tab.Users => TuiTexts.KeysUsers, + Tab.Explore => ExploreHint(), + _ => TuiTexts.KeysLog, + }; + + var sb = new StringBuilder("\e[H"); + sb.Append(TopBorder(width)).Append("\e[K\r\n"); + foreach (var line in lines) + sb.Append(Frame).Append("│ ").Append(Terminal.Reset).Append(Terminal.FitVisible(line, inner)) + .Append(Terminal.Reset).Append(Frame).Append(" │").Append(Terminal.Reset).Append("\e[K\r\n"); + sb.Append(Frame).Append('├').Append(new string('─', width - 2)).Append('┤').Append(Terminal.Reset).Append("\e[K\r\n"); + sb.Append(Frame).Append("│ ").Append(Terminal.Reset).Append(Muted).Append(Terminal.Fit(_status ?? "", inner)) + .Append(Terminal.Reset).Append(Frame).Append(" │").Append(Terminal.Reset).Append("\e[K\r\n"); + sb.Append(BottomBorder(width, hint + " · " + TuiTexts.KeysCommon)).Append("\e[K"); + sb.Append(Terminal.Reset); + Terminal.Draw(sb.ToString()); + } + + /// ╭─ Sunfish 3.0 ─┤ F1 Estado ├─ F2 Shares ─ ... ─ ● En marcha :8080 ─╮ + private string TopBorder(int width) + { + string[] names = [Strings.AppStatus, Strings.AppShares, Strings.AppUsers, TuiTexts.ExploreTab, Strings.AppLog]; + var sb = new StringBuilder(Frame + "╭─ " + Terminal.Reset + Terminal.Bold + Accent + "Sunfish 3.0" + Terminal.Reset + Frame + " ─"); + int used = 3 + 11 + 2; + for (int i = 0; i < names.Length; i++) + { + string label = $" F{i + 1} {names[i]} "; + if (i == (int)_tab) + sb.Append('┤').Append(Terminal.Reset).Append(Terminal.Reverse).Append(Terminal.Bold).Append(label) + .Append(Terminal.Reset).Append(Frame).Append('├'); + else + sb.Append('─').Append(Terminal.Reset).Append(label).Append(Frame).Append('─'); + used += label.Length + 2; + } + string ports = (_host.HttpRunning ? $" http:{_host.HttpPort}" : "") + (_host.FtpRunning ? $" ftp:{_host.FtpPort}" : ""); + string state = _host.Running ? $" ● {Strings.AppRunning}{ports} " : $" ○ {Strings.AppStopped} "; + string color = _host.Running ? Terminal.Fg(90, 200, 110) : Terminal.Fg(230, 90, 80); + int gap = width - used - state.Length - 2; + if (gap >= 1) + sb.Append(new string('─', gap)).Append(Terminal.Reset).Append(color).Append(state).Append(Frame).Append("─╮"); + else + sb.Append(new string('─', Math.Max(0, width - used - 1))).Append('╮'); + sb.Append(Terminal.Reset); + return sb.ToString(); + } + + /// ╰─ atajos ──────────────╯ + private static string BottomBorder(int width, string hint) + { + string text = Terminal.Fit(" " + hint + " ", Math.Max(0, width - 5)).TrimEnd() + " "; + int rest = Math.Max(0, width - 3 - text.Length); + return Frame + "╰─" + Terminal.Reset + Muted + text + Terminal.Reset + Frame + new string('─', rest) + "╯" + Terminal.Reset; + } + + /// ":8080", ":2121 (off)" o ":2121 !" si debia estar en marcha y no lo esta (puerto ocupado). + private string Service(bool running, bool enabled, int port) + { + if (running) + return $"{Terminal.Fg(90, 200, 110)}:{port}{Terminal.Reset}"; + if (!enabled) + return $"{Muted}:{port} (off){Terminal.Reset}"; + return _host.Running ? $"{Terminal.Fg(230, 90, 80)}:{port} !{Terminal.Reset}" : $":{port}"; + } + + private List StatusBody(int width, int height) + { + var info = new List + { + Terminal.Bold + Accent + _host.Config.Server.Name + Terminal.Reset, + (_host.Running ? Terminal.Fg(90, 200, 110) + "● " + Strings.AppRunning : Terminal.Fg(230, 90, 80) + "○ " + Strings.AppStopped) + + Terminal.Reset + $" HTTP: {Service(_host.HttpRunning, _host.Config.Server.Http, _host.Config.Server.HttpPort)}" + + $" FTP: {Service(_host.FtpRunning, _host.Config.Server.Ftp, _host.Config.Server.FtpPort)}", + Muted + (IsQuick ? Strings.AppQuickShare(_host.Config.Shares.FirstOrDefault()?.Path ?? "") : TuiTexts.Config(_host.Config.FilePath!)) + Terminal.Reset, + "", + Terminal.Bold + Strings.AppAddresses + Terminal.Reset, + }; + var shares = _host.Config.Shares.Where(s => s.Enabled).ToList(); + if (shares.Count == 0) + info.Add(" " + Strings.AppNoShares); + var addresses = LocalAddresses.Get(); + foreach (var share in shares) + { + info.Add($" {Accent}▸{Terminal.Reset} {share.Name}"); + foreach (var address in addresses) + { + if (_host.HttpRunning || !_host.Running) + info.Add($" {LocalAddresses.HttpUrl(address.Address, _host.HttpPort, share)} {Muted}{address.Interface}{Terminal.Reset}"); + if (_host.FtpRunning && share.Ftp) + info.Add($" {LocalAddresses.FtpUrl(address.Address, _host.FtpPort, share)} {Muted}{address.Interface}{Terminal.Reset}"); + } + } + + bool showIcon = width >= 96 && height >= 24; + if (!showIcon) + return info; + + _icon ??= TuiIcon.Render(); + var lines = new List(); + int count = Math.Max(TuiIcon.Rows + 1, info.Count); + for (int i = 0; i < count; i++) + { + string left = i >= 1 && i <= TuiIcon.Rows ? _icon[i - 1] + " " : new string(' ', TuiIcon.Columns + 2); + lines.Add(left + (i < info.Count ? info[i] : "")); + } + return lines; + } + + private List SharesBody(int width) + { + var lines = new List(); + var shares = _host.Config.Shares; + int nameW = 14, locW = 16, permW = 7, usersW = 12; + int pathW = Math.Max(10, width - nameW - locW - permW - usersW - 7); + string Row(string mark, string name, string loc, string path, string perms, string users) => + mark + " " + Terminal.Fit(name, nameW) + " " + Terminal.Fit(loc, locW) + " " + + Terminal.Fit(path, pathW) + " " + Terminal.Fit(perms, permW) + " " + Terminal.Fit(users, usersW); + + lines.Add(Terminal.Bold + Row(" ", Strings.FieldName, "Location", Strings.FieldPath, TuiTexts.ColPerms, TuiTexts.ColUsers) + Terminal.Reset); + lines.Add(Frame + new string('─', width) + Terminal.Reset); + if (shares.Count == 0) + lines.Add(Muted + Strings.AppNoShares + Terminal.Reset); + for (int i = 0; i < shares.Count; i++) + { + var s = shares[i]; + string perms = (s.Listing ? "L" : "·") + (s.Upload ? "U" : "·") + (s.Overwrite ? "O" : "·") + + (s.Rename ? "R" : "·") + (s.Delete ? "D" : "·") + (s.Zip ? "Z" : "·"); + string row = Row(s.Enabled ? "●" : "○", s.Name, s.Location, s.Path, perms, string.Join(",", s.Users)); + lines.Add(i == _shareSel ? Terminal.Reverse + row + Terminal.Reset : (s.Enabled ? row : Muted + row + Terminal.Reset)); + } + lines.Add(""); + lines.Add(Muted + TuiTexts.Permissions + Terminal.Reset); + return lines; + } + + private List UsersBody(int width) + { + var lines = new List(); + var users = _host.Config.Users; + if (users.Count == 0) + lines.Add(Muted + TuiTexts.NoUsers + Terminal.Reset); + for (int i = 0; i < users.Count; i++) + { + var u = users[i]; + var shares = _host.Config.Shares.Where(s => s.Users.Contains(u.Name, StringComparer.OrdinalIgnoreCase)).Select(s => s.Name); + string row = "◆ " + Terminal.Fit(u.Name, 20) + " " + Terminal.Fit(TuiTexts.UsedIn(string.Join(", ", shares)), Math.Max(10, width - 24)); + lines.Add(i == _userSel ? Terminal.Reverse + row + Terminal.Reset : row); + } + return lines; + } + + private List LogBody(int width, int height) + { + var entries = _host.Log.Recent(); + return entries.Skip(Math.Max(0, entries.Length - height)).Select(e => + { + string color = e.Level switch + { + LogLevel.Error => Terminal.Fg(230, 90, 80), + LogLevel.Warning => Terminal.Fg(230, 190, 80), + LogLevel.Detail => Muted, + _ => "", + }; + return Muted + $"{e.Time:HH:mm:ss} " + Terminal.Reset + color + Terminal.Fit(e.Message, Math.Max(10, width - 9)) + Terminal.Reset; + }).ToList(); + } + + private static string OverlayHint(Overlay overlay) => overlay switch + { + ShareForm => TuiTexts.KeysForm, + QrOverlay => TuiTexts.KeysQr, + InputOverlay => TuiTexts.KeysInput, + ConfirmOverlay => TuiTexts.YesNo, + _ => "", + }; + + /// Recuadro redondeado centrado sobre el cuerpo; tapa las lineas que ocupa. + private void DrawOverlay(List lines, Overlay overlay, int width, int height) + { + int boxWidth = Math.Min(width - 2, 76); + string title = ""; + var content = new List<(string Text, bool Highlight)>(); + switch (overlay) + { + case MessageOverlay m: + content.Add((m.Text, false)); + break; + case ConfirmOverlay c: + content.Add((c.Question + " " + TuiTexts.YesNo, false)); + break; + case InputOverlay input: + title = input.Prompt; + string value = input.Mask ? new string('•', input.Text.Length) : input.Text.ToString(); + int room = boxWidth - 6; + if (value.Length > room) + value = "…" + value[^(room - 1)..]; + content.Add(("› " + value + "▏", true)); + break; + case ShareForm form: + title = TuiTexts.EditShare(form.Share.Name); + int row = 0; + foreach (var field in TextFields) + content.Add(($"{Terminal.Fit(FieldLabel(field), 34)} {FieldValue(form.Share, field)}", form.Selected == row++)); + content.Add((new string('─', boxWidth - 4), false)); + foreach (var field in BoolFields) + content.Add(($"{(FieldBool(form.Share, field) ? "☑" : "☐")} {FieldLabel(field)}", form.Selected == row++)); + content.Add((new string('─', boxWidth - 4), false)); + content.Add((TuiTexts.Save, form.Selected == row++)); + content.Add((TuiTexts.Cancel, form.Selected == row)); + if (form.Share.Upload && form.Share.Listing) + content.Add(("⚠ " + Strings.WarnUploadListing, false)); + break; + } + + int boxHeight = content.Count + 2; + int top = Math.Max(0, (height - boxHeight) / 2); + int left = Math.Max(0, (width - boxWidth) / 2); + string pad = new(' ', left); + string titleText = title.Length > 0 ? Terminal.Fit(" " + title + " ", Math.Min(title.Length + 2, boxWidth - 4)) : ""; + string topLine = Accent + "╭─" + Terminal.Reset + Terminal.Bold + titleText + Terminal.Reset + Accent + + new string('─', Math.Max(0, boxWidth - 3 - titleText.Length)) + "╮" + Terminal.Reset; + string bottomLine = Accent + "╰" + new string('─', boxWidth - 2) + "╯" + Terminal.Reset; + void Put(int index, string text) + { + if (index >= 0 && index < lines.Count) + lines[index] = pad + text; + } + Put(top, topLine); + for (int i = 0; i < content.Count; i++) + { + var (text, highlight) = content[i]; + string innerText = Terminal.Fit(" " + text, boxWidth - 2); + Put(top + 1 + i, Accent + "│" + Terminal.Reset + (highlight ? Terminal.Reverse + innerText + Terminal.Reset : innerText) + Accent + "│" + Terminal.Reset); + } + Put(top + 1 + content.Count, bottomLine); + } +} diff --git a/src/Sunfish.Cli/Tui/TuiExplorer.cs b/src/Sunfish.Cli/Tui/TuiExplorer.cs new file mode 100644 index 0000000..bd15f52 --- /dev/null +++ b/src/Sunfish.Cli/Tui/TuiExplorer.cs @@ -0,0 +1,329 @@ +using System.Globalization; +using Sunfish.Dav; +using Sunfish.Net; + +namespace Sunfish.Cli.Tui; + +/// +/// Pestana Explorar: otros Sunfish de la red (DNS-SD) o cualquier WebDAV por direccion, con +/// descarga y subida en los dos sentidos. Las operaciones de red van en segundo plano y la +/// pantalla se redibuja al terminar o al avanzar. +/// +internal sealed partial class TuiApp +{ + private List? _servers; + private int _serverSel; + private bool _searching; + private DavClient? _dav; + private string? _davUser, _davPassword; + private string _davFolder = "/"; + private List _davItems = new(); + private int _davSel; + private string? _davBusy; + private string? _davMessage; + + private bool Browsing => _dav is not null; + + private string ExploreHint() => Browsing ? TuiTexts.KeysExploreBrowse : TuiTexts.KeysExploreServers; + + /// Teclas de la pestana; devuelve true si las ha usado (Esc vuelve a la lista de servidores). + private async Task ExploreKeyAsync(ConsoleKeyInfo key) + { + if (_servers is null && !_searching) + StartSearch(); + if (_davBusy is not null) + return key.Key != ConsoleKey.Q; // durante una operacion solo se puede salir + if (!Browsing) + { + switch (key.Key) + { + case ConsoleKey.UpArrow: _serverSel = Math.Max(0, _serverSel - 1); return true; + case ConsoleKey.DownArrow: _serverSel = Math.Min(Math.Max(0, (_servers?.Count ?? 1) - 1), _serverSel + 1); return true; + case ConsoleKey.R: StartSearch(); return true; + case ConsoleKey.A: + _overlays.Add(new InputOverlay(TuiTexts.ExploreAddress, "", false, text => { Connect(text); return Task.CompletedTask; })); + return true; + case ConsoleKey.Enter when _servers is { Count: > 0 }: + Connect(_servers[_serverSel].Url); + return true; + } + return false; + } + + var item = _davItems.Count > 0 ? _davItems[Math.Clamp(_davSel, 0, _davItems.Count - 1)] : null; + switch (key.Key) + { + case ConsoleKey.Escape: + _dav?.Dispose(); + _dav = null; + _davMessage = null; + return true; + case ConsoleKey.UpArrow: _davSel = Math.Max(0, _davSel - 1); return true; + case ConsoleKey.DownArrow: _davSel = Math.Min(Math.Max(0, _davItems.Count - 1), _davSel + 1); return true; + case ConsoleKey.PageUp: _davSel = Math.Max(0, _davSel - 10); return true; + case ConsoleKey.PageDown: _davSel = Math.Min(Math.Max(0, _davItems.Count - 1), _davSel + 10); return true; + case ConsoleKey.Backspace: + if (_davFolder != "/") + Run(TuiTexts.ExploreLoading, () => LoadAsync(DavClient.Parent(_davFolder))); + return true; + case ConsoleKey.R: + Run(TuiTexts.ExploreLoading, () => LoadAsync(_davFolder)); + return true; + case ConsoleKey.Enter when item is { IsCollection: true }: + Run(TuiTexts.ExploreLoading, () => LoadAsync(item.Path)); + return true; + case ConsoleKey.D or ConsoleKey.Enter when item is not null: + _overlays.Add(new InputOverlay(TuiTexts.ExploreDownloadTo, Environment.CurrentDirectory, false, text => + { + string target = text.Trim().Trim('"'); + if (!Directory.Exists(target)) + { + _overlays.Add(new MessageOverlay(Strings.ErrorNotAFolder(target))); + return Task.CompletedTask; + } + Run(TuiTexts.ExploreDownloading(item.Name), async () => + { + await WithLoginAsync(c => c.DownloadTreeAsync(item, target, Progress())); + _davMessage = TuiTexts.ExploreDone; + }); + return Task.CompletedTask; + })); + return true; + case ConsoleKey.U: + _overlays.Add(new InputOverlay(TuiTexts.ExploreUploadFrom, Environment.CurrentDirectory, false, text => + { + string source = text.Trim().Trim('"'); + if (!File.Exists(source) && !Directory.Exists(source)) + { + _overlays.Add(new MessageOverlay(Strings.ErrorNotAFolder(source))); + return Task.CompletedTask; + } + string folder = _davFolder; + Run(TuiTexts.ExploreUploading(Path.GetFileName(Path.TrimEndingDirectorySeparator(source))), async () => + { + await WithLoginAsync(c => c.UploadTreeAsync(source, folder, Progress())); + await LoadAsync(folder); + _davMessage = TuiTexts.ExploreDone; + }); + return Task.CompletedTask; + })); + return true; + case ConsoleKey.M: + _overlays.Add(new InputOverlay(TuiTexts.ExploreNewFolder, "", false, name => + { + if (name.Trim().Length == 0) + return Task.CompletedTask; + string folder = _davFolder; + Run(TuiTexts.ExploreLoading, async () => + { + await WithLoginAsync(c => c.CreateFolderAsync(DavClient.Combine(folder, name.Trim()))); + await LoadAsync(folder); + }); + return Task.CompletedTask; + })); + return true; + case ConsoleKey.Delete when item is not null: + _overlays.Add(new ConfirmOverlay(TuiTexts.ExploreDeleteQuestion(item.Name), () => + { + string folder = _davFolder; + Run(TuiTexts.ExploreLoading, async () => + { + await WithLoginAsync(c => c.DeleteAsync(item.Path, item.IsCollection)); + await LoadAsync(folder); + }); + return Task.CompletedTask; + })); + return true; + } + return false; + } + + private void StartSearch() + { + _searching = true; + _ = Task.Run(async () => + { + try + { + _servers = await Discovery.BrowseAsync(TimeSpan.FromSeconds(2)); + } + catch (Exception e) when (e is System.Net.Sockets.SocketException or OperationCanceledException) + { + _servers = new(); + } + _searching = false; + _serverSel = 0; + _events.Writer.TryWrite(null); + }); + } + + private void Connect(string address) + { + if (!DavClient.TryParseAddress(address, out var server, out var path)) + { + _overlays.Add(new MessageOverlay(TuiTexts.ExploreBadAddress)); + return; + } + _dav?.Dispose(); + _dav = new DavClient(server, _davUser, _davPassword); + _davItems = new(); + Run(TuiTexts.ExploreLoading, () => LoadAsync(path)); + } + + private async Task LoadAsync(string folder) + { + var items = await WithLoginAsync(c => c.ListAsync(folder)); + _davFolder = folder.Length > 1 ? folder.TrimEnd('/') : "/"; + _davItems = items; + _davSel = 0; + _davMessage = TuiTexts.ExploreItems(items.Count); + } + + /// Con 401 se piden usuario y contrasena (en la TUI) y se repite. + private async Task WithLoginAsync(Func> action) + { + while (true) + { + try + { + return await action(_dav!); + } + catch (DavException e) when (e.Status == 401) + { + var credentials = await AskCredentialsAsync(_dav!.Server.Authority); + if (credentials is null) + throw; + (_davUser, _davPassword) = credentials.Value; + var server = _dav.Server; + _dav.Dispose(); + _dav = new DavClient(server, _davUser, _davPassword); + } + } + } + + private Task WithLoginAsync(Func action) => + WithLoginAsync(async c => { await action(c); return true; }); + + /// Muestra dos preguntas y espera las respuestas (las teclas las atiende el bucle principal). + private Task<(string, string)?> AskCredentialsAsync(string server) + { + var done = new TaskCompletionSource<(string, string)?>(); + _overlays.Add(new InputOverlay(TuiTexts.ExploreUser(server), _davUser ?? "", false, user => + { + _overlays.Add(new InputOverlay(Strings.FieldPassword + ":", "", true, password => + { + done.TrySetResult((user.Trim(), password)); + return Task.CompletedTask; + })); + return Task.CompletedTask; + })); + _events.Writer.TryWrite(null); + // Si se cancela con Esc no hay respuesta: se da por cancelado a los dos minutos. + _ = Task.Delay(TimeSpan.FromMinutes(2)).ContinueWith(_ => done.TrySetResult(null), TaskScheduler.Default); + return done.Task; + } + + /// Lanza una operacion de red en segundo plano con un mensaje de espera. + private void Run(string busy, Func action) + { + _davBusy = busy; + _ = Task.Run(async () => + { + try + { + await action(); + } + catch (DavException e) + { + _davMessage = e.Status == 403 ? Strings.ErrorText(403) : e.Message; + } + catch (Exception e) when (e is HttpRequestException or IOException or UnauthorizedAccessException or TaskCanceledException) + { + _davMessage = e.Message; + } + finally + { + _davBusy = null; + _events.Writer.TryWrite(null); + } + }); + } + + private IProgress Progress() + { + long total = 0; + string busy = _davBusy ?? ""; + return new Progress(n => + { + total += n; + _davBusy = $"{busy} {FormatSize(total)}"; + _events.Writer.TryWrite(null); + }); + } + + private List ExploreBody(int width, int height) + { + if (_servers is null && !_searching) + StartSearch(); + var lines = new List(); + if (!Browsing) + { + lines.Add(Terminal.Bold + TuiTexts.ExploreServersTitle + Terminal.Reset); + lines.Add(Frame + new string('─', width) + Terminal.Reset); + if (_searching) + lines.Add(Muted + TuiTexts.ExploreSearching + Terminal.Reset); + else if (_servers is { Count: 0 }) + lines.Add(Muted + TuiTexts.ExploreNoneFound + Terminal.Reset); + for (int i = 0; _servers is not null && i < _servers.Count; i++) + { + var s = _servers[i]; + string row = $"{(s.IsSunfish ? "◆" : "◇")} {Terminal.Fit(s.Name, 30)} {s.Address}:{s.Port}"; + lines.Add(i == _serverSel ? Terminal.Reverse + Terminal.Fit(row, width) + Terminal.Reset : row); + } + AddExploreStatus(lines); + return lines; + } + + lines.Add(Terminal.Bold + Accent + _dav!.Server.Authority + Terminal.Reset + _davFolder); + lines.Add(Frame + new string('─', width) + Terminal.Reset); + int room = Math.Max(3, height - 5); + int first = Math.Clamp(_davSel - room / 2, 0, Math.Max(0, _davItems.Count - room)); + if (_davFolder != "/") + lines.Add(Muted + "▲ .." + Terminal.Reset); + int nameWidth = Math.Max(10, width - 32); + for (int i = first; i < Math.Min(_davItems.Count, first + room); i++) + { + var e = _davItems[i]; + string row = (e.IsCollection ? Accent + "▸ " + Terminal.Reset : " ") + Terminal.Fit(e.Name + (e.IsCollection ? "/" : ""), nameWidth) + " " + + Terminal.Fit(e.IsCollection ? "" : FormatSize(e.Size), 10) + " " + + Terminal.Fit(e.Modified?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) ?? "", 16); + lines.Add(i == _davSel ? Terminal.Reverse + Terminal.FitVisible(row, width) + Terminal.Reset : row); + } + if (_davItems.Count == 0 && _davBusy is null) + lines.Add(Muted + Strings.EmptyFolder + Terminal.Reset); + AddExploreStatus(lines); + return lines; + } + + private void AddExploreStatus(List lines) + { + lines.Add(""); + if (_davBusy is not null) + lines.Add(Accent + "⟳ " + _davBusy + Terminal.Reset); + else if (_davMessage is not null) + lines.Add(Muted + _davMessage + Terminal.Reset); + } + + private static string FormatSize(long bytes) + { + string[] units = ["B", "KB", "MB", "GB", "TB"]; + double value = bytes; + int unit = 0; + while (value >= 1024 && unit < units.Length - 1) + { + value /= 1024; + unit++; + } + return unit == 0 ? $"{bytes} B" : value.ToString("0.0", CultureInfo.InvariantCulture) + " " + units[unit]; + } +} diff --git a/src/Sunfish.Cli/Tui/TuiIcon.cs b/src/Sunfish.Cli/Tui/TuiIcon.cs new file mode 100644 index 0000000..1234480 --- /dev/null +++ b/src/Sunfish.Cli/Tui/TuiIcon.cs @@ -0,0 +1,71 @@ +using System.Globalization; +using System.Text; + +namespace Sunfish.Cli.Tui; + +/// +/// El pez luna del icono, en 16x16 pixeles (art/logo.png reducido). Se dibuja con medios bloques: +/// cada celda es dos pixeles, el de arriba como color de texto y el de abajo como fondo. +/// "......" es transparente. +/// +internal static class TuiIcon +{ + public const int Columns = 16; + public const int Rows = 8; + + private static readonly string[] Pixels = + [ + "...... ...... ...... ...... ...... ...... ...... ...... ...... ...... ...... ...... ...... ...... ...... ......", + "...... ...... ...... ...... ...... ...... 94A3D4 8588C1 ...... ...... ...... ...... ...... ...... ...... ......", + "...... ...... ...... ...... ...... 7E86BF E6FFFF 9EA8D1 ...... ...... ...... ...... ...... ...... ...... ......", + "...... ...... ...... ...... ...... 87A7D3 EAFFFF 98A1CB ...... ...... ...... ...... ...... ...... ...... ......", + "...... ...... ...... ...... 827CBC ADCAE5 E0F9FD B7CCE6 A3A2CC A298C7 8475B4 ...... ...... ...... ...... ......", + "...... ...... ...... 94AAD7 D3F8FF DCF2FA E2F2F9 F6FFFF FFFFFF FEFFFF EBFAFF B0BADB ...... ...... ...... ......", + "...... ...... 91ACD8 B0E8FF ADDCF2 C8E6F6 CFEBF7 DCF1F8 E1F2FA DAEFF8 D4EEF9 E7FFFF A4B1D7 ...... B6E0EE ......", + "...... ...... 87C4EF 9AD9F3 A0D6F0 BDE3F5 C5E6F7 C4E3F3 B5DEF1 ABD9F1 B9E2F5 9EC4E3 ADD8F6 7789C2 ...... ......", + "...... ...... 75C1EF 94D4F1 98D3EF ADDCF3 BCE2F5 A7D5ED 70BFE5 8DCDEC B7E0F3 9CCCE9 98D5F7 6877B8 ...... ......", + "...... ...... 62B5EA 91D5F3 96D1EE 95D1EF 97D2EF 9ED5F0 91CFEC 9FD5F0 9ED4EF A5E0F9 7FC3F2 ...... ...... ......", + "...... ...... 487EBF 5FC7F5 76CDF2 83CCEE 8CCEED 8ACFEE 8AD1F1 87CFF0 82D2F4 66C3F5 4366AE ...... ...... ......", + "...... ...... ...... 4580C2 3E8DC8 42A2D3 51B5E3 4FB0E1 4BA7DC 4CA9DD 4495CE 4265AD ...... ...... ...... ......", + "...... ...... ...... ...... ...... 4289C2 45BFF2 4377B9 ...... ...... ...... ...... ...... ...... ...... ......", + "...... ...... ...... ...... ...... 4685C1 4BBFF7 4F59A4 ...... ...... ...... ...... ...... ...... ...... ......", + "...... ...... ...... ...... ...... 4971B2 4CAFED ...... ...... ...... ...... ...... ...... ...... ...... ......", + "...... ...... ...... ...... ...... ...... 4C64AD ...... ...... ...... ...... ...... ...... ...... ...... ......", + ]; + + /// Las 8 lineas del icono con sus secuencias de color; cada una ocupa 16 columnas. + public static string[] Render() + { + var grid = Pixels.Select(row => row.Split(' ')).ToArray(); + var lines = new string[Rows]; + for (int row = 0; row < Rows; row++) + { + var sb = new StringBuilder(); + for (int x = 0; x < Columns; x++) + { + var top = Parse(grid[row * 2][x]); + var bottom = Parse(grid[row * 2 + 1][x]); + if (top is null && bottom is null) + sb.Append(Terminal.Reset).Append(' '); + else if (bottom is null) + sb.Append(Terminal.Reset).Append(Terminal.Fg(top!.Value.R, top.Value.G, top.Value.B)).Append('▀'); // ▀ + else if (top is null) + sb.Append(Terminal.Reset).Append(Terminal.Fg(bottom.Value.R, bottom.Value.G, bottom.Value.B)).Append('▄'); // ▄ + else + sb.Append(Terminal.Fg(top.Value.R, top.Value.G, top.Value.B)) + .Append(Terminal.Bg(bottom.Value.R, bottom.Value.G, bottom.Value.B)).Append('▀'); + } + sb.Append(Terminal.Reset); + lines[row] = sb.ToString(); + } + return lines; + } + + private static (int R, int G, int B)? Parse(string hex) + { + if (hex.StartsWith('.')) + return null; + int v = int.Parse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture); + return (v >> 16, (v >> 8) & 0xFF, v & 0xFF); + } +} diff --git a/src/Sunfish.Cli/Tui/TuiTexts.cs b/src/Sunfish.Cli/Tui/TuiTexts.cs new file mode 100644 index 0000000..ee79a95 --- /dev/null +++ b/src/Sunfish.Cli/Tui/TuiTexts.cs @@ -0,0 +1,65 @@ +namespace Sunfish.Cli.Tui; + +/// Textos de la TUI (el idioma lo decide Sunfish.Strings). +internal static class TuiTexts +{ + private static string T(string english, string spanish) => Strings.Spanish ? spanish : english; + + public static string KeysCommon => T("Tab: next tab Q: quit", "Tab: pestaña Q: salir"); + public static string KeysStatus => T("S: start/stop P: HTTP port F: FTP on/off T: FTP port N: name C: QR code", "S: arrancar/parar P: puerto HTTP F: FTP sí/no T: puerto FTP N: nombre C: código QR"); + public static string KeysQr => T("←/→: other address any other key: close", "←/→: otra dirección otra tecla: cerrar"); + public static string NoQr => T("No address to show: start the server with an enabled share.", "No hay direcciones: arranca el servidor con algún share activo."); + public static string QrTooSmall => T("The terminal is too small for the QR code; this is the address:", "El terminal es demasiado pequeño para el código QR; esta es la dirección:"); + public static string KeysShares => T("A/Ins: add Enter: edit Space: on/off D/Del: remove", "A/Ins: añadir Enter: editar Espacio: activar D/Supr: quitar"); + public static string KeysUsers => T("A/Ins: add P: password D/Del: delete", "A/Ins: añadir P: contraseña D/Supr: borrar"); + public static string KeysLog => T("Newest at the bottom", "Lo más reciente, abajo"); + public static string ExploreTab => T("Explore", "Explorar"); + public static string KeysExploreServers => T("Enter: connect A: type an address R: search again", "Enter: conectar A: escribir dirección R: buscar otra vez"); + public static string KeysExploreBrowse => T("Enter: open Bksp: up D: download U: upload M: new folder Del: delete Esc: servers", + "Enter: abrir Retroceso: subir D: descargar U: subir aquí M: carpeta Supr: borrar Esc: servidores"); + public static string ExploreServersTitle => T("Servers on the local network", "Servidores en la red local"); + public static string ExploreSearching => T("Searching the network...", "Buscando en la red..."); + public static string ExploreNoneFound => T("No servers found. Press A to type an address.", "No se han encontrado servidores. Pulsa A para escribir una dirección."); + public static string ExploreAddress => T("Address (host:port or http://...):", "Dirección (equipo:puerto o http://...):"); + public static string ExploreBadAddress => T("Invalid address.", "Dirección no válida."); + public static string ExploreLoading => T("Loading...", "Cargando..."); + public static string ExploreItems(int n) => T($"{n} item(s)", $"{n} elemento(s)"); + public static string ExploreDone => T("Done.", "Hecho."); + public static string ExploreDownloadTo => T("Download to the local folder:", "Descargar en la carpeta local:"); + public static string ExploreUploadFrom => T("Local file or folder to upload here:", "Fichero o carpeta local a subir aquí:"); + public static string ExploreNewFolder => T("Name of the new folder:", "Nombre de la carpeta nueva:"); + public static string ExploreDownloading(string name) => T($"Downloading {name}...", $"Descargando {name}..."); + public static string ExploreUploading(string name) => T($"Uploading {name}...", $"Subiendo {name}..."); + public static string ExploreDeleteQuestion(string name) => T($"Delete '{name}' on the other server?", $"¿Borrar '{name}' en el otro servidor?"); + public static string ExploreUser(string server) => T($"User for {server}:", $"Usuario para {server}:"); + public static string KeysForm => T("Up/Down: move Enter: edit/toggle Esc: cancel", "Arriba/Abajo: mover Enter: editar/cambiar Esc: cancelar"); + public static string KeysInput => T("Enter: accept Esc: cancel", "Enter: aceptar Esc: cancelar"); + public static string YesNo => T("(y/n)", "(s/n)"); + public static bool IsYes(char c) => char.ToLowerInvariant(c) is 'y' or 's'; + + public static string QuitQuestion => T("Quit? The server will stop.", "¿Salir? El servidor se detendrá."); + public static string Save => T("[ Save ]", "[ Guardar ]"); + public static string Cancel => T("[ Cancel ]", "[ Cancelar ]"); + public static string NewShareFolder => T("Folder to share:", "Carpeta a compartir:"); + public static string EditShare(string name) => T($"Share '{name}'", $"Share '{name}'"); + public static string NewUserName => T("Name of the new user:", "Nombre del usuario nuevo:"); + public static string NewPassword(string name) => T($"Password for '{name}':", $"Contraseña de '{name}':"); + public static string RepeatPassword => T("Repeat the password:", "Repite la contraseña:"); + public static string PasswordMismatch => T("The passwords do not match.", "Las contraseñas no coinciden."); + public static string PortPrompt => T("HTTP port (1-65535):", "Puerto HTTP (1-65535):"); + public static string FtpPortPrompt => T("FTP port (1-65535):", "Puerto FTP (1-65535):"); + public static string NamePrompt => T("Server name:", "Nombre del servidor:"); + public static string BadPort => T("Invalid port.", "Puerto no válido."); + public static string Config(string path) => T($"Configuration: {path}", $"Configuración: {path}"); + public static string NoUsers => T("No users. Shares without users are public.", "No hay usuarios. Los shares sin usuarios son públicos."); + public static string UsedIn(string shares) => T($"shares: {shares}", $"shares: {shares}"); + public static string Permissions => T("L=list U=upload O=overwrite R=rename D=delete Z=zip", "L=listar U=subir O=sobrescribir R=renombrar D=borrar Z=zip"); + public static string ColPerms => T("Perms", "Permisos"); + public static string ColUsers => T("Users", "Usuarios"); + public static string Saved => T("Saved.", "Guardado."); + public static string NotSavedQuick => T("Quick share: changes are not saved to a file.", "Compartido al momento: los cambios no se guardan en ningún fichero."); + + public static string NoConfigQuestion(string portable, string user) => T( + $"There is no configuration file yet. Create it:\n 1) next to the program: {portable}\n 2) in your user folder: {user}\n Esc) quit\nChoose 1 or 2: ", + $"Aún no hay fichero de configuración. Crearlo:\n 1) junto al programa: {portable}\n 2) en tu carpeta de usuario: {user}\n Esc) salir\nElige 1 o 2: "); +} diff --git a/src/Sunfish.Cli/UserCommands.cs b/src/Sunfish.Cli/UserCommands.cs new file mode 100644 index 0000000..dd02422 --- /dev/null +++ b/src/Sunfish.Cli/UserCommands.cs @@ -0,0 +1,149 @@ +using System.Text; +using Sunfish.Auth; +using Sunfish.Config; + +namespace Sunfish.Cli; + +/// sunfish-cli user add|passwd|del|list: usuarios del fichero de configuracion. +internal static class UserCommands +{ + public static int Run(string[] args) + { + string? configPath = null; + var rest = new List(); + for (int i = 0; i < args.Length; i++) + { + if (args[i] is "-c" or "--config" && i + 1 < args.Length) + configPath = args[++i]; + else if (args[i] == "--lang" && i + 1 < args.Length) + Strings.Force(args[++i]); + else + rest.Add(args[i]); + } + if (rest.Count == 0 || (rest[0] != "list" && rest.Count != 2)) + { + Console.Error.WriteLine(Texts.UserUsage); + return 1; + } + + string path = ConfigLocator.Find(configPath) ?? ConfigLocator.PortablePath; + var config = File.Exists(path) ? SunfishConfig.Load(path) : new SunfishConfig { FilePath = path }; + string verb = rest[0]; + string name = rest.Count > 1 ? rest[1] : ""; + var user = config.Users.FirstOrDefault(u => u.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + + switch (verb) + { + case "list": + if (config.Users.Count == 0) + Console.WriteLine(Texts.NoUsers); + foreach (var u in config.Users) + { + var shares = config.Shares.Where(s => s.Users.Contains(u.Name, StringComparer.OrdinalIgnoreCase)).Select(s => s.Name); + Console.WriteLine($"{u.Name} ({Texts.UserShares(string.Join(", ", shares))})"); + } + return 0; + + case "add": + if (!IsValidName(name)) + { + Console.Error.WriteLine(Texts.BadUserName(name)); + return 1; + } + if (user is not null) + { + Console.Error.WriteLine(Texts.UserExists(name)); + return 1; + } + string? password = AskNewPassword(); + if (password is null) + return 1; + config.Users.Add(new UserConfig { Name = name, PasswordHash = Passwords.Hash(password) }); + config.Save(path); + Console.WriteLine(Texts.UserSaved(name, path)); + Console.WriteLine(Texts.GrantHint); + return 0; + + case "passwd": + if (user is null) + { + Console.Error.WriteLine(Texts.UserMissing(name)); + return 1; + } + string? changed = AskNewPassword(); + if (changed is null) + return 1; + user.PasswordHash = Passwords.Hash(changed); + config.Save(path); + Console.WriteLine(Texts.UserSaved(user.Name, path)); + return 0; + + case "del": + if (user is null) + { + Console.Error.WriteLine(Texts.UserMissing(name)); + return 1; + } + config.Users.Remove(user); + foreach (var share in config.Shares) + share.Users.RemoveAll(u => u.Equals(user.Name, StringComparison.OrdinalIgnoreCase)); + config.Save(path); + Console.WriteLine(Texts.UserDeleted(user.Name)); + return 0; + + default: + Console.Error.WriteLine(Texts.UserUsage); + return 1; + } + } + + /// Sin espacios, comas ni ':' (separa usuario y contrasena en HTTP Basic). + internal static bool IsValidName(string name) => + name.Length is > 0 and <= 64 && name.All(c => c > ' ' && c != ',' && c != ':' && c != '[' && c != ']' && c != 0x7F); + + internal static string? AskNewPassword() + { + string first = ReadPassword(Texts.PasswordPrompt); + if (first.Length == 0) + { + Console.Error.WriteLine(Texts.PasswordEmpty); + return null; + } + if (!Console.IsInputRedirected && ReadPassword(Texts.PasswordRepeat) != first) + { + Console.Error.WriteLine(Texts.PasswordMismatch); + return null; + } + return first; + } + + /// Lee sin eco en un terminal; con la entrada redirigida, una linea. + private static string ReadPassword(string prompt) + { + Console.Error.Write(prompt); + if (Console.IsInputRedirected) + { + string line = Console.ReadLine() ?? ""; + Console.Error.WriteLine(); + return line; + } + var sb = new StringBuilder(); + while (true) + { + var key = Console.ReadKey(intercept: true); + if (key.Key == ConsoleKey.Enter) + break; + if (key.Key == ConsoleKey.Backspace) + { + if (sb.Length > 0) + sb.Length--; + } + else if (!char.IsControl(key.KeyChar)) + { + sb.Append(key.KeyChar); + } + } + Console.Error.WriteLine(); + return sb.ToString(); + } +} diff --git a/src/Sunfish.Core/Auth/Authenticator.cs b/src/Sunfish.Core/Auth/Authenticator.cs new file mode 100644 index 0000000..6bf2126 --- /dev/null +++ b/src/Sunfish.Core/Auth/Authenticator.cs @@ -0,0 +1,131 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Security.Cryptography; +using System.Text; +using Sunfish.Config; + +namespace Sunfish.Auth; + +public enum AuthResult { Ok, Missing, Wrong, Blocked } + +/// +/// Comprueba usuarios y contrasenas (HTTP Basic, y mas adelante FTP). +/// HTTP Basic reenvia la contrasena en cada peticion: las ya verificadas se recuerdan un rato +/// (como HMAC con una clave de esta ejecucion, nunca en claro) para no recalcular PBKDF2. +/// Los fallos repetidos desde una IP la bloquean un tiempo. +/// +public sealed class Authenticator +{ + public const int MaxFailures = 5; + public static readonly TimeSpan FailureWindow = TimeSpan.FromMinutes(5); + public static readonly TimeSpan BlockTime = TimeSpan.FromMinutes(1); + public static readonly TimeSpan CacheTime = TimeSpan.FromMinutes(10); + + private readonly byte[] _key = RandomNumberGenerator.GetBytes(32); + private readonly ConcurrentDictionary _verified = new(); + private readonly ConcurrentDictionary _failures = new(); + + private sealed class Failures + { + public int Count; + public DateTime First; + public DateTime BlockedUntil; + } + + /// Reloj inyectable para las pruebas. + internal Func Now { get; init; } = () => DateTime.UtcNow; + + public AuthResult Check(IEnumerable users, IReadOnlyCollection allowed, + string? userName, string? password, IPAddress? from) + { + if (from is not null && IsBlocked(from)) + return AuthResult.Blocked; + if (userName is null || password is null) + return AuthResult.Missing; + + var user = users.FirstOrDefault(u => string.Equals(u.Name, userName, StringComparison.OrdinalIgnoreCase)); + bool ok = user is not null && + allowed.Contains(user.Name, StringComparer.OrdinalIgnoreCase) && + VerifyCached(user, password); + if (ok) + return AuthResult.Ok; + if (from is not null) + RegisterFailure(from); + return AuthResult.Wrong; + } + + private bool VerifyCached(UserConfig user, string password) + { + string key = Convert.ToBase64String(HMACSHA256.HashData(_key, + Encoding.UTF8.GetBytes(user.Name + "\0" + password + "\0" + user.PasswordHash))); + var now = Now(); + if (_verified.TryGetValue(key, out var until) && until > now) + return true; + if (!Passwords.Verify(password, user.PasswordHash)) + return false; + _verified[key] = now + CacheTime; + if (_verified.Count > 1000) + foreach (var (k, v) in _verified) + if (v <= now) + _verified.TryRemove(k, out _); + return true; + } + + private bool IsBlocked(IPAddress from) => + _failures.TryGetValue(from, out var f) && f.BlockedUntil > Now(); + + private void RegisterFailure(IPAddress from) + { + var now = Now(); + var f = _failures.GetOrAdd(from, _ => new Failures { First = now }); + lock (f) + { + if (now - f.First > FailureWindow) + { + f.Count = 0; + f.First = now; + } + if (++f.Count >= MaxFailures) + { + f.BlockedUntil = now + BlockTime; + f.Count = 0; + f.First = now; + } + } + } + + /// + /// "Basic dXNlcjpwYXNz" -> (usuario, contrasena). Los bytes se leen como UTF-8 y, si no lo + /// son, como Latin-1 (navegadores antiguos). + /// + public static bool TryParseBasic(string? header, out string user, out string password) + { + user = password = ""; + if (header is null || !header.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase)) + return false; + byte[] bytes; + try + { + bytes = Convert.FromBase64String(header[6..].Trim()); + } + catch (FormatException) + { + return false; + } + string text; + try + { + text = new UTF8Encoding(false, true).GetString(bytes); + } + catch (DecoderFallbackException) + { + text = Encoding.Latin1.GetString(bytes); + } + int colon = text.IndexOf(':'); + if (colon < 0) + return false; + user = text[..colon]; + password = text[(colon + 1)..]; + return true; + } +} diff --git a/src/Sunfish.Core/Auth/Passwords.cs b/src/Sunfish.Core/Auth/Passwords.cs new file mode 100644 index 0000000..e7ab016 --- /dev/null +++ b/src/Sunfish.Core/Auth/Passwords.cs @@ -0,0 +1,45 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +namespace Sunfish.Auth; + +/// +/// Hashes de contrasena con PBKDF2-SHA256: "pbkdf2-sha256$iteraciones$sal$hash" (Base64). +/// La contrasena nunca se guarda en claro. +/// +public static class Passwords +{ + public const int DefaultIterations = 100_000; + private const string Prefix = "pbkdf2-sha256"; + private const int SaltBytes = 16; + private const int HashBytes = 32; + + public static string Hash(string password, int iterations = DefaultIterations) + { + byte[] salt = RandomNumberGenerator.GetBytes(SaltBytes); + byte[] hash = Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, iterations, HashAlgorithmName.SHA256, HashBytes); + return string.Join('$', Prefix, iterations.ToString(CultureInfo.InvariantCulture), + Convert.ToBase64String(salt), Convert.ToBase64String(hash)); + } + + public static bool Verify(string password, string stored) + { + var parts = stored.Split('$'); + if (parts.Length != 4 || parts[0] != Prefix || + !int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out int iterations) || iterations <= 0) + return false; + byte[] salt, expected; + try + { + salt = Convert.FromBase64String(parts[2]); + expected = Convert.FromBase64String(parts[3]); + } + catch (FormatException) + { + return false; + } + byte[] actual = Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, iterations, HashAlgorithmName.SHA256, expected.Length); + return CryptographicOperations.FixedTimeEquals(actual, expected); + } +} diff --git a/src/Sunfish.Core/Config/ConfigLocator.cs b/src/Sunfish.Core/Config/ConfigLocator.cs new file mode 100644 index 0000000..7a0b0ec --- /dev/null +++ b/src/Sunfish.Core/Config/ConfigLocator.cs @@ -0,0 +1,43 @@ +namespace Sunfish.Config; + +/// Donde se busca y donde se crea sunfish.conf. +public static class ConfigLocator +{ + public const string FileName = "sunfish.conf"; + + /// Junto al ejecutable: modo portable. + public static string PortablePath => Path.Combine(AppContext.BaseDirectory, FileName); + + /// Carpeta de configuracion del usuario segun el sistema. + public static string UserPath + { + get + { + string baseDir; + if (OperatingSystem.IsWindows()) + baseDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Sunfish"); + else if (OperatingSystem.IsMacOS()) + baseDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Sunfish"); + else + { + string? xdg = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); + if (string.IsNullOrWhiteSpace(xdg)) + xdg = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config"); + baseDir = Path.Combine(xdg, "sunfish"); + } + return Path.Combine(baseDir, FileName); + } + } + + /// El fichero indicado, el portable o el del usuario: el primero que exista; null si ninguno. + public static string? Find(string? explicitPath = null) + { + if (!string.IsNullOrEmpty(explicitPath)) + return explicitPath; + if (File.Exists(PortablePath)) + return PortablePath; + if (File.Exists(UserPath)) + return UserPath; + return null; + } +} diff --git a/src/Sunfish.Core/Config/IniDocument.cs b/src/Sunfish.Core/Config/IniDocument.cs new file mode 100644 index 0000000..a52541f --- /dev/null +++ b/src/Sunfish.Core/Config/IniDocument.cs @@ -0,0 +1,173 @@ +using System.Text; + +namespace Sunfish.Config; + +/// +/// Fichero INI que conserva comentarios, lineas en blanco y orden al reescribirse. +/// Comentarios: lineas que empiezan por '#' o ';'. Claves y secciones sin distinguir mayusculas. +/// +internal sealed class IniDocument +{ + private readonly List _sections = new(); + + public IniDocument() => _sections.Add(new IniSection(null, "")); + + /// Lineas que no se pudieron interpretar (numero de linea, texto). + public List<(int Line, string Text)> Errors { get; } = new(); + + /// Lo que hay antes de la primera seccion. + public IniSection Preamble => _sections[0]; + + public IEnumerable Sections => _sections.Skip(1); + + public static IniDocument Parse(string text) + { + var doc = new IniDocument(); + IniSection current = doc._sections[0]; + int number = 0; + foreach (var rawLine in text.Split('\n')) + { + number++; + string line = rawLine.TrimEnd('\r'); + string trimmed = line.Trim(); + if (trimmed.Length == 0 || trimmed[0] == '#' || trimmed[0] == ';') + { + current.Lines.Add(new IniLine(line, null, null, number)); + } + else if (trimmed[0] == '[' && trimmed[^1] == ']') + { + current = new IniSection(line, trimmed[1..^1].Trim()) { LineNumber = number }; + doc._sections.Add(current); + } + else + { + int eq = line.IndexOf('='); + if (eq <= 0) + { + doc.Errors.Add((number, line)); + current.Lines.Add(new IniLine(line, null, null, number)); + } + else + { + current.Lines.Add(new IniLine(line, line[..eq].Trim(), line[(eq + 1)..].Trim(), number)); + } + } + } + // Split deja una linea vacia final si el texto acaba en salto de linea: no se duplica al guardar. + var last = doc._sections[^1].Lines; + if (last.Count > 0 && last[^1].Raw.Length == 0 && text.EndsWith('\n')) + last.RemoveAt(last.Count - 1); + return doc; + } + + public IniSection? Find(string name) => + Sections.FirstOrDefault(s => string.Equals(s.Name, name, StringComparison.OrdinalIgnoreCase)); + + public IniSection GetOrAdd(string name) + { + var section = Find(name); + if (section is not null) + return section; + // Separa la seccion nueva de la anterior con una linea en blanco. + var previous = _sections[^1]; + if (_sections.Count > 1 || previous.Lines.Count > 0) + { + if (previous.Lines.Count == 0 || previous.Lines[^1].Raw.Trim().Length > 0) + previous.Lines.Add(new IniLine("", null, null, 0)); + } + section = new IniSection($"[{name}]", name); + _sections.Add(section); + return section; + } + + public void Remove(IniSection section) + { + if (section != _sections[0]) + _sections.Remove(section); + } + + public string ToText(string newLine) + { + var sb = new StringBuilder(); + foreach (var section in _sections) + { + if (section.Header is not null) + sb.Append(section.Header).Append(newLine); + foreach (var line in section.Lines) + sb.Append(line.Raw).Append(newLine); + } + return sb.ToString(); + } +} + +internal sealed class IniSection +{ + public IniSection(string? header, string name) + { + Header = header; + Name = name; + } + + /// Linea original de la cabecera; null en el preambulo. + public string? Header { get; } + + /// Texto entre corchetes, p. ej. "Share fotos". + public string Name { get; } + + public int LineNumber { get; init; } + + public List Lines { get; } = new(); + + /// Primera palabra del nombre: "Share" en "[Share fotos]". + public string Kind + { + get + { + int sp = Name.IndexOf(' '); + return sp < 0 ? Name : Name[..sp]; + } + } + + /// Resto del nombre: "fotos" en "[Share fotos]". + public string Title + { + get + { + int sp = Name.IndexOf(' '); + return sp < 0 ? "" : Name[(sp + 1)..].Trim(); + } + } + + public IEnumerable Entries => Lines.Where(l => l.Key is not null); + + public IniLine? FindEntry(string key) => + Entries.FirstOrDefault(l => string.Equals(l.Key, key, StringComparison.OrdinalIgnoreCase)); + + public string? Get(string key) => FindEntry(key)?.Value; + + public bool Has(string key) => FindEntry(key) is not null; + + /// Cambia el valor conservando la posicion de la clave; si no existe, la anade al final. + public void Set(string key, string value) + { + int index = Lines.FindIndex(l => string.Equals(l.Key, key, StringComparison.OrdinalIgnoreCase)); + if (index >= 0) + { + var old = Lines[index]; + Lines[index] = new IniLine($"{old.Key} = {value}", old.Key, value, old.Number); + // Una clave repetida se queda solo con su primera aparicion. + for (int i = Lines.Count - 1; i > index; i--) + if (string.Equals(Lines[i].Key, key, StringComparison.OrdinalIgnoreCase)) + Lines.RemoveAt(i); + return; + } + // Detras de la ultima clave, para no quedar despues de los blancos que separan secciones. + int insert = Lines.FindLastIndex(l => l.Key is not null) + 1; + Lines.Insert(insert, new IniLine($"{key} = {value}", key, value, 0)); + } + + public void Remove(string key) => + Lines.RemoveAll(l => string.Equals(l.Key, key, StringComparison.OrdinalIgnoreCase)); +} + +internal sealed record IniLine(string Raw, string? Key, string? Value, int Number); diff --git a/src/Sunfish.Core/Config/LegacyImport.cs b/src/Sunfish.Core/Config/LegacyImport.cs new file mode 100644 index 0000000..7d58907 --- /dev/null +++ b/src/Sunfish.Core/Config/LegacyImport.cs @@ -0,0 +1,167 @@ +using System.Text.Json; + +namespace Sunfish.Config; + +/// +/// Importacion de la configuracion de Sunfish 2.0 (JSON "sunfish2", ver DOC/SHARES-Y-RUTAS.md). +/// Usa JsonDocument (sin reflexion, compatible con AOT) y lee los nombres sin distinguir +/// mayusculas. Lo que ya no existe se ignora y se explica en las notas. +/// +public static class LegacyImport +{ + public const string FileName = "sunfish2"; + + /// "sunfish2" junto al ejecutable o en la carpeta actual (donde lo dejaba la 2.0), o null. + public static string? Find() + { + foreach (var dir in new[] { AppContext.BaseDirectory, Environment.CurrentDirectory }) + { + string path = Path.Combine(dir, FileName); + if (File.Exists(path)) + return path; + } + return null; + } + + /// Convierte el JSON de la 2.0. Devuelve null si no es un JSON valido de la 2.0. + public static SunfishConfig? FromV2(string json, List notes) + { + JsonDocument doc; + try + { + doc = JsonDocument.Parse(json, new JsonDocumentOptions { AllowTrailingCommas = true, CommentHandling = JsonCommentHandling.Skip }); + } + catch (JsonException) + { + return null; + } + using (doc) + { + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) + return null; + + var config = new SunfishConfig(); + if (Get(root, "Port") is { } portElement && Int(portElement) is int port) + { + // Sin administrador no se abren puertos por debajo de 1024 (la 2.0 usaba 90 por defecto). + if (port is >= 1024 and <= 65535) + config.Server.HttpPort = port; + else + notes.Add(Strings.ImportPortChanged(port, config.Server.HttpPort)); + } + if (Get(root, "SunfishRoot") is { } menu && Bool(menu) is bool rootMenu) + config.Server.RootMenu = rootMenu; + if (Get(root, "AdminPanel") is { } admin && Bool(admin) == true) + notes.Add(Strings.ImportAdminIgnored); + + if (Get(root, "Services") is { ValueKind: JsonValueKind.Array } services) + { + foreach (var service in services.EnumerateArray()) + { + if (service.ValueKind != JsonValueKind.Object) + continue; + string name = Str(Get(service, "Name")) ?? "share"; + string? type = Str(Get(service, "Type")); + if (type is not null && !type.EndsWith("WebService", StringComparison.OrdinalIgnoreCase)) + { + notes.Add(Strings.ImportServiceSkipped(name, type)); + continue; + } + var settings = Get(service, "Settings"); + var share = new ShareConfig + { + Name = ShareSettings.SuggestName(config, name), + Enabled = Get(service, "Enabled") is { } en && Bool(en) is bool enabled ? enabled : true, + Path = Str(Setting(settings, "path")) ?? "", + Index = Str(Setting(settings, "defaultFile")) ?? "", + Listing = Setting(settings, "folderShare") is { } fs && Bool(fs) == true, + }; + string location = Str(Get(service, "Location")) ?? ""; + share.Location = Shares.LocationPath.TryNormalize(location.StartsWith('/') ? location : "/" + location, out var normalized) + ? normalized + : ShareSettings.SuggestLocation(config, share.Name); + // readOnly ausente en la 2.0 equivalia a false (escritura permitida). + bool readOnly = Setting(settings, "readOnly") is { } ro && Bool(ro) == true; + if (!readOnly) + share.Upload = share.Overwrite = share.Rename = true; + share.Delete = !readOnly && Setting(settings, "allowDeletion") is { } del && Bool(del) == true; + if (Setting(settings, "allowServerExecution") is { } exec && Bool(exec) == true) + notes.Add(Strings.ImportExecIgnored(share.Name)); + if (share.Path.Length == 0 || !Directory.Exists(share.Path)) + notes.Add(Strings.ImportMissingFolder(share.Name, share.Path)); + config.Shares.Add(share); + } + } + return config; + } + } + + /// + /// Anade a los shares importados (con nombre y location libres si + /// chocan). El puerto y el menu raiz solo se copian si la configuracion es nueva. Devuelve + /// cuantos shares se anadieron. + /// + public static int Merge(SunfishConfig target, SunfishConfig imported, bool isNew) + { + if (isNew) + { + target.Server.HttpPort = imported.Server.HttpPort; + target.Server.RootMenu = imported.Server.RootMenu; + } + foreach (var share in imported.Shares) + { + share.Name = ShareSettings.SuggestName(target, share.Name); + if (target.Shares.Any(s => s.Location.Equals(share.Location, StringComparison.OrdinalIgnoreCase))) + share.Location = ShareSettings.SuggestLocation(target, share.Location.Trim('/')); + target.Shares.Add(share); + } + return imported.Shares.Count; + } + + private static JsonElement? Get(JsonElement? obj, string name) + { + if (obj is not { ValueKind: JsonValueKind.Object } o) + return null; + foreach (var p in o.EnumerateObject()) + if (p.Name.Equals(name, StringComparison.OrdinalIgnoreCase)) + return p.Value; + return null; + } + + /// Settings era un Dictionary<string, object>: objeto o lista de pares Key/Value. + private static JsonElement? Setting(JsonElement? settings, string key) + { + if (settings is { ValueKind: JsonValueKind.Array } list) + { + foreach (var pair in list.EnumerateArray()) + if (Str(Get(pair, "Key")) is { } k && k.Equals(key, StringComparison.OrdinalIgnoreCase)) + return Get(pair, "Value"); + return null; + } + return Get(settings, key); + } + + private static string? Str(JsonElement? e) => e switch + { + { ValueKind: JsonValueKind.String } s => s.GetString(), + { ValueKind: JsonValueKind.Number } n => n.GetRawText(), + _ => null, + }; + + private static bool? Bool(JsonElement e) => e.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String when ShareSettings.TryParseBool(e.GetString() ?? "", out bool b) => b, + JsonValueKind.Number when e.TryGetInt32(out int n) => n != 0, + _ => null, + }; + + private static int? Int(JsonElement e) => e.ValueKind switch + { + JsonValueKind.Number when e.TryGetInt32(out int n) => n, + JsonValueKind.String when int.TryParse(e.GetString(), out int n) => n, + _ => null, + }; +} diff --git a/src/Sunfish.Core/Config/ShareSettings.cs b/src/Sunfish.Core/Config/ShareSettings.cs new file mode 100644 index 0000000..f321bcd --- /dev/null +++ b/src/Sunfish.Core/Config/ShareSettings.cs @@ -0,0 +1,119 @@ +using System.Globalization; + +namespace Sunfish.Config; + +/// +/// Cambiar opciones de un share por nombre de clave (las del fichero INI). Lo usan los +/// subcomandos, la TUI y el panel web, para que todos acepten lo mismo. +/// +public static class ShareSettings +{ + /// Claves que se pueden cambiar con . + public static readonly string[] Keys = + [ + "Location", "Path", "Index", "Enabled", "Listing", "Upload", "Overwrite", "Rename", "Delete", + "Zip", "Ftp", "WebDav", "Mac", "ShortNames", "Users", + ]; + + /// Aplica "clave=valor". Devuelve un mensaje de error o null si fue bien. + public static string? TrySet(ShareConfig share, string key, string value) + { + value = value.Trim(); + switch (key.ToLowerInvariant()) + { + case "location": + if (!Shares.LocationPath.TryNormalize(value, out var location)) + return Strings.ConfigBadLocation(share.Name, value); + share.Location = location; + return null; + case "path": + if (value.Length == 0) + return Strings.ConfigMissingPath(share.Name); + share.Path = value; + return null; + case "index": + share.Index = value; + return null; + case "users": + share.Users = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); + return null; + } + if (!TryParseBool(value, out bool flag)) + return Strings.ConfigBadBool(key, value); + switch (key.ToLowerInvariant()) + { + case "enabled": share.Enabled = flag; break; + case "listing": share.Listing = flag; break; + case "upload": share.Upload = flag; break; + case "overwrite": share.Overwrite = flag; break; + case "rename": share.Rename = flag; break; + case "delete": share.Delete = flag; break; + case "zip": share.Zip = flag; break; + case "ftp": share.Ftp = flag; break; + case "webdav": share.WebDav = flag; break; + case "mac": share.Mac = flag; break; + case "shortnames": share.ShortNames = flag; break; + default: return Strings.UnknownShareKey(key); + } + return null; + } + + public static bool TryParseBool(string text, out bool value) + { + switch (text.Trim().ToLower(CultureInfo.InvariantCulture)) + { + case "true": case "yes": case "on": case "1": case "si": case "sí": value = true; return true; + case "false": case "no": case "off": case "0": value = false; return true; + default: value = false; return false; + } + } + + /// Location libre para un share nuevo a partir de un nombre: /fotos, /fotos-2... + public static string SuggestLocation(SunfishConfig config, string name) + { + string baseName = new string(name.Where(c => !char.IsControl(c) && c is not ('/' or '\\' or ':' or '%')).ToArray()).Trim(); + if (baseName.Length == 0 || baseName.StartsWith('$')) + baseName = "share"; + string candidate = "/" + baseName; + for (int i = 2; config.Shares.Any(s => s.Location.Equals(candidate, StringComparison.OrdinalIgnoreCase)); i++) + candidate = $"/{baseName}-{i}"; + return candidate; + } + + /// Nombre libre para un share nuevo. + public static string SuggestName(SunfishConfig config, string name) + { + string baseName = new string(name.Where(c => c > ' ' && c is not ('[' or ']')).ToArray()); + if (baseName.Length == 0) + baseName = "share"; + string candidate = baseName; + for (int i = 2; config.Shares.Any(s => s.Name.Equals(candidate, StringComparison.OrdinalIgnoreCase)); i++) + candidate = $"{baseName}-{i}"; + return candidate; + } + + /// + /// Comprueba un share antes de guardarlo: nombre valido y libre, location no usada por otro + /// share activo y carpeta existente. Devuelve el problema o null. + /// + public static string? Validate(SunfishConfig config, ShareConfig share, string? originalName) + { + if (!IsValidName(share.Name)) + return Strings.ErrorBadName(share.Name); + var others = config.Shares.Where(x => !string.Equals(x.Name, originalName, StringComparison.OrdinalIgnoreCase)).ToList(); + if (others.Any(x => x.Name.Equals(share.Name, StringComparison.OrdinalIgnoreCase))) + return Strings.ErrorNameInUse(share.Name); + if (!Shares.LocationPath.TryNormalize(share.Location, out var location)) + return Strings.ConfigBadLocation(share.Name, share.Location); + share.Location = location; + if (share.Enabled && others.Any(x => x.Enabled && x.Location.Equals(location, StringComparison.OrdinalIgnoreCase))) + return Strings.ErrorNameInUse(location); + if (!Directory.Exists(share.Path)) + return Strings.ErrorNotAFolder(share.Path); + return null; + } + + /// Un nombre de share o de usuario valido en el INI: sin espacios, comas, ':' ni corchetes. + public static bool IsValidName(string name) => + name.Length is > 0 and <= 64 && name.All(c => c > ' ' && c != ',' && c != ':' && c != '[' && c != ']' && c != 0x7F); +} diff --git a/src/Sunfish.Core/Config/SunfishConfig.cs b/src/Sunfish.Core/Config/SunfishConfig.cs new file mode 100644 index 0000000..fe3a203 --- /dev/null +++ b/src/Sunfish.Core/Config/SunfishConfig.cs @@ -0,0 +1,403 @@ +using System.Globalization; +using System.Net; +using Sunfish.Shares; + +namespace Sunfish.Config; + +/// Seccion [Server]. +public sealed class ServerSettings +{ + public string Name { get; set; } = "Sunfish"; + public bool Http { get; set; } = true; + public int HttpPort { get; set; } = 8080; + public bool Ftp { get; set; } = true; + public int FtpPort { get; set; } = 2121; + + /// Rango de puertos para el modo pasivo, "50000-50050" (vacio: cualquiera libre). + public string FtpPassivePorts { get; set; } = "50000-50050"; + + /// IP que se anuncia en PASV si el servidor esta detras de NAT (null: la local). + public IPAddress? FtpPublicAddress { get; set; } + + /// Juego de caracteres de los nombres para clientes que no piden UTF-8. + public string FtpCharset { get; set; } = "iso-8859-1"; + + public List Bind { get; set; } = new() { IPAddress.Any, IPAddress.IPv6Any }; + public bool RootMenu { get; set; } = true; + public bool SelfDownload { get; set; } = true; + public bool Announce { get; set; } = true; +} + +/// Seccion [Share nombre]: una carpeta publicada en una location. +public sealed class ShareConfig +{ + public string Name { get; set; } = ""; + public string Location { get; set; } = "/"; + public string Path { get; set; } = ""; + public string Index { get; set; } = "index.html"; + public bool Enabled { get; set; } = true; + public bool Listing { get; set; } = true; + public bool Upload { get; set; } + public bool Overwrite { get; set; } + public bool Rename { get; set; } + public bool Delete { get; set; } + public bool Zip { get; set; } + public bool Ftp { get; set; } = true; + public bool WebDav { get; set; } = true; + public bool Mac { get; set; } = true; + public bool ShortNames { get; set; } = true; + public List Users { get; set; } = new(); + + public ShareConfig Clone() + { + var copy = (ShareConfig)MemberwiseClone(); + copy.Users = new List(Users); + return copy; + } +} + +/// Seccion [User nombre]. +public sealed class UserConfig +{ + public string Name { get; set; } = ""; + public string PasswordHash { get; set; } = ""; +} + +/// Seccion [Admin]. +public sealed class AdminSettings +{ + public bool Enabled { get; set; } + public string PasswordHash { get; set; } = ""; + public List AllowFrom { get; set; } = new() { "127.0.0.1", "::1" }; +} + +/// +/// Configuracion completa (sunfish.conf). Se lee de un INI y se vuelve a escribir sobre el +/// mismo documento, asi se conservan comentarios y orden. +/// +public sealed class SunfishConfig +{ + private readonly IniDocument _doc; + + public SunfishConfig() : this(new IniDocument()) { } + + private SunfishConfig(IniDocument doc) => _doc = doc; + + public ServerSettings Server { get; } = new(); + public AdminSettings Admin { get; } = new(); + public List Shares { get; } = new(); + public List Users { get; } = new(); + + /// Fichero del que se leyo (null si se creo en memoria). + public string? FilePath { get; set; } + + /// Errores encontrados al leer; la configuracion se usa igualmente. + public List Errors { get; } = new(); + + /// + /// Copia independiente (mismo texto, comentarios incluidos) para editarla sin tocar la que + /// esta sirviendo; se aplica despues con SunfishHost.ApplyAsync. + /// + public SunfishConfig Clone() + { + var copy = Parse(ToText()); + copy.FilePath = FilePath; + return copy; + } + + public static SunfishConfig Load(string path) + { + var config = Parse(File.ReadAllText(path)); + config.FilePath = path; + return config; + } + + public static SunfishConfig Parse(string text) + { + var doc = IniDocument.Parse(text); + var config = new SunfishConfig(doc); + foreach (var (line, _) in doc.Errors) + config.Errors.Add(Strings.ConfigLine(line, Strings.ConfigBadLine)); + foreach (var section in doc.Sections) + { + var reader = new SectionReader(section, config.Errors); + switch (section.Kind.ToLowerInvariant()) + { + case "server": + config.ReadServer(reader); + break; + case "admin": + config.ReadAdmin(reader); + break; + case "share": + config.Shares.Add(ReadShare(reader, section.Title)); + break; + case "user": + config.Users.Add(new UserConfig { Name = section.Title, PasswordHash = reader.String("PasswordHash", "") }); + break; + default: + config.Errors.Add(Strings.ConfigLine(section.LineNumber, Strings.ConfigUnknownSection(section.Name))); + break; + } + } + config.Validate(); + return config; + } + + private void ReadServer(SectionReader r) + { + Server.Name = r.String("Name", Server.Name); + Server.Http = r.Bool("Http", Server.Http); + Server.HttpPort = r.Port("HttpPort", Server.HttpPort); + Server.Ftp = r.Bool("Ftp", Server.Ftp); + Server.FtpPort = r.Port("FtpPort", Server.FtpPort); + Server.FtpPassivePorts = r.String("FtpPassivePorts", Server.FtpPassivePorts); + Server.FtpCharset = r.String("FtpCharset", Server.FtpCharset); + if (r.String("FtpPublicAddress", "") is { Length: > 0 } publicAddress) + { + if (IPAddress.TryParse(publicAddress, out var parsed)) + Server.FtpPublicAddress = parsed; + else + r.Error(Strings.ConfigBadAddress(publicAddress)); + } + Server.RootMenu = r.Bool("RootMenu", Server.RootMenu); + Server.SelfDownload = r.Bool("SelfDownload", Server.SelfDownload); + Server.Announce = r.Bool("Announce", Server.Announce); + var bind = r.List("Bind"); + if (bind is not null) + { + var addresses = new List(); + foreach (var item in bind) + { + if (IPAddress.TryParse(item, out var address)) + addresses.Add(address); + else + r.Error(Strings.ConfigBadAddress(item)); + } + if (addresses.Count > 0) + Server.Bind = addresses; + } + } + + private void ReadAdmin(SectionReader r) + { + Admin.Enabled = r.Bool("Enabled", Admin.Enabled); + Admin.PasswordHash = r.String("PasswordHash", ""); + Admin.AllowFrom = r.List("AllowFrom") ?? Admin.AllowFrom; + } + + private static ShareConfig ReadShare(SectionReader r, string name) + { + var s = new ShareConfig { Name = name }; + s.Location = r.String("Location", s.Location); + s.Path = r.String("Path", s.Path); + s.Index = r.String("Index", s.Index); + s.Enabled = r.Bool("Enabled", s.Enabled); + s.Listing = r.Bool("Listing", s.Listing); + s.Upload = r.Bool("Upload", s.Upload); + s.Overwrite = r.Bool("Overwrite", s.Overwrite); + s.Rename = r.Bool("Rename", s.Rename); + s.Delete = r.Bool("Delete", s.Delete); + s.Zip = r.Bool("Zip", s.Zip); + s.Ftp = r.Bool("Ftp", s.Ftp); + s.WebDav = r.Bool("WebDav", s.WebDav); + s.Mac = r.Bool("Mac", s.Mac); + s.ShortNames = r.Bool("ShortNames", s.ShortNames); + s.Users = r.List("Users") ?? s.Users; + return s; + } + + /// Comprueba lo que no depende de una sola clave: locations validas y unicas, rutas. + private void Validate() + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var share in Shares) + { + if (!LocationPath.TryNormalize(share.Location, out var normalized)) + { + Errors.Add(Strings.ConfigBadLocation(share.Name, share.Location)); + share.Enabled = false; + continue; + } + share.Location = normalized; + if (string.IsNullOrWhiteSpace(share.Path)) + { + Errors.Add(Strings.ConfigMissingPath(share.Name)); + share.Enabled = false; + } + if (share.Enabled && !seen.Add(normalized)) + { + Errors.Add(Strings.ConfigDuplicateLocation(share.Name, share.Location)); + share.Enabled = false; + } + } + } + + /// Texto INI con los valores actuales, sobre el documento original. + public string ToText() + { + WriteServer(_doc.GetOrAdd("Server")); + var admin = _doc.Find("Admin"); + if (admin is not null || Admin.Enabled || Admin.PasswordHash.Length > 0) + WriteAdmin(admin ?? _doc.GetOrAdd("Admin")); + + SyncSections("User", Users.Select(u => u.Name), (section, name) => + { + var user = Users.First(u => u.Name == name); + section.Set("PasswordHash", user.PasswordHash); + }); + SyncSections("Share", Shares.Select(s => s.Name), (section, name) => + WriteShare(section, Shares.First(s => s.Name == name))); + + return _doc.ToText(OperatingSystem.IsWindows() ? "\r\n" : "\n"); + } + + /// Guarda de forma atomica: fichero temporal y renombrado. + public void Save(string? path = null) + { + path ??= FilePath ?? throw new InvalidOperationException("No config path"); + string full = System.IO.Path.GetFullPath(path); + string? dir = System.IO.Path.GetDirectoryName(full); + if (dir is not null) + Directory.CreateDirectory(dir); + string temp = full + ".tmp"; + File.WriteAllText(temp, ToText()); + File.Move(temp, full, overwrite: true); + FilePath = full; + } + + private void SyncSections(string kind, IEnumerable names, Action write) + { + var wanted = names.ToList(); + foreach (var section in _doc.Sections.Where(s => s.Kind.Equals(kind, StringComparison.OrdinalIgnoreCase)).ToList()) + { + if (!wanted.Contains(section.Title, StringComparer.OrdinalIgnoreCase)) + _doc.Remove(section); + } + foreach (var name in wanted) + write(_doc.GetOrAdd($"{kind} {name}"), name); + } + + private void WriteServer(IniSection s) + { + var d = new ServerSettings(); + SetIf(s, "Name", Server.Name, d.Name); + SetIf(s, "Http", Server.Http, d.Http); + SetIf(s, "HttpPort", Server.HttpPort, d.HttpPort); + SetIf(s, "Ftp", Server.Ftp, d.Ftp); + SetIf(s, "FtpPort", Server.FtpPort, d.FtpPort); + SetIf(s, "FtpPassivePorts", Server.FtpPassivePorts, d.FtpPassivePorts); + SetIf(s, "FtpPublicAddress", Server.FtpPublicAddress?.ToString() ?? "", ""); + SetIf(s, "FtpCharset", Server.FtpCharset, d.FtpCharset); + SetIf(s, "Bind", string.Join(", ", Server.Bind), string.Join(", ", d.Bind)); + SetIf(s, "RootMenu", Server.RootMenu, d.RootMenu); + SetIf(s, "SelfDownload", Server.SelfDownload, d.SelfDownload); + SetIf(s, "Announce", Server.Announce, d.Announce); + } + + private void WriteAdmin(IniSection s) + { + s.Set("Enabled", Format(Admin.Enabled)); + s.Set("PasswordHash", Admin.PasswordHash); + s.Set("AllowFrom", string.Join(", ", Admin.AllowFrom)); + } + + private static void WriteShare(IniSection s, ShareConfig share) + { + var d = new ShareConfig(); + s.Set("Location", share.Location); + s.Set("Path", share.Path); + SetIf(s, "Index", share.Index, d.Index); + SetIf(s, "Enabled", share.Enabled, d.Enabled); + SetIf(s, "Listing", share.Listing, d.Listing); + SetIf(s, "Upload", share.Upload, d.Upload); + SetIf(s, "Overwrite", share.Overwrite, d.Overwrite); + SetIf(s, "Rename", share.Rename, d.Rename); + SetIf(s, "Delete", share.Delete, d.Delete); + SetIf(s, "Zip", share.Zip, d.Zip); + SetIf(s, "Ftp", share.Ftp, d.Ftp); + SetIf(s, "WebDav", share.WebDav, d.WebDav); + SetIf(s, "Mac", share.Mac, d.Mac); + SetIf(s, "ShortNames", share.ShortNames, d.ShortNames); + SetIf(s, "Users", string.Join(", ", share.Users), ""); + } + + /// Escribe la clave si ya estaba en el fichero o si el valor no es el de por defecto. + private static void SetIf(IniSection s, string key, string value, string defaultValue) + { + if (s.Has(key) || value != defaultValue) + s.Set(key, value); + } + + private static void SetIf(IniSection s, string key, bool value, bool defaultValue) + { + // "yes", "on" o "1" se respetan si siguen significando lo mismo. + if (s.Get(key) is { } current && TryParseBool(current, out bool same) && same == value) + return; + SetIf(s, key, Format(value), Format(defaultValue)); + } + + private static bool TryParseBool(string text, out bool value) + { + switch (text.Trim().ToLowerInvariant()) + { + case "true": case "yes": case "on": case "1": value = true; return true; + case "false": case "no": case "off": case "0": value = false; return true; + default: value = false; return false; + } + } + + private static void SetIf(IniSection s, string key, int value, int defaultValue) => + SetIf(s, key, value.ToString(CultureInfo.InvariantCulture), defaultValue.ToString(CultureInfo.InvariantCulture)); + + private static string Format(bool value) => value ? "true" : "false"; + + /// Lectura tipada de una seccion, acumulando errores con su numero de linea. + private sealed class SectionReader(IniSection section, List errors) + { + private int _line = section.LineNumber; + + public void Error(string message) => errors.Add(Strings.ConfigLine(_line, message)); + + private string? Raw(string key) + { + var entry = section.FindEntry(key); + if (entry is null) + return null; + _line = entry.Number; + return entry.Value; + } + + public string String(string key, string defaultValue) => Raw(key) ?? defaultValue; + + public bool Bool(string key, bool defaultValue) + { + string? value = Raw(key); + if (value is null) + return defaultValue; + if (TryParseBool(value, out bool result)) + return result; + Error(Strings.ConfigBadBool(key, value)); + return defaultValue; + } + + public int Port(string key, int defaultValue) + { + string? value = Raw(key); + if (value is null) + return defaultValue; + if (int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out int port) && port is >= 0 and <= 65535) + return port; + Error(Strings.ConfigBadNumber(key, value)); + return defaultValue; + } + + public List? List(string key) + { + string? value = Raw(key); + if (value is null) + return null; + return value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); + } + } +} diff --git a/src/Sunfish.Core/Dav/DavClient.cs b/src/Sunfish.Core/Dav/DavClient.cs new file mode 100644 index 0000000..d555fd7 --- /dev/null +++ b/src/Sunfish.Core/Dav/DavClient.cs @@ -0,0 +1,292 @@ +using System.Globalization; +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Xml; +using Sunfish.Shares; + +namespace Sunfish.Dav; + +/// Un elemento de una carpeta remota. +public sealed record DavItem(string Name, string Path, bool IsCollection, long Size, DateTime? Modified); + +/// Error de un servidor WebDAV (401: hace falta usuario y contrasena). +public sealed class DavException(int status, string message) : Exception(message) +{ + public int Status { get; } = status; +} + +/// +/// Cliente WebDAV minimo para explorar otro Sunfish (o cualquier servidor WebDAV) y copiar en los +/// dos sentidos. Las rutas son absolutas y sin codificar ("/fotos/2026"); se codifican aqui. +/// +public sealed class DavClient : IDisposable +{ + private static readonly HttpMethod PropFind = new("PROPFIND"); + private static readonly HttpMethod MkCol = new("MKCOL"); + private static readonly HttpMethod Move = new("MOVE"); + + private readonly HttpClient _http; + + public DavClient(Uri server, string? user = null, string? password = null) + { + Server = new Uri(server.GetLeftPart(UriPartial.Authority) + "/"); + _http = new HttpClient(new SocketsHttpHandler { AllowAutoRedirect = true, PooledConnectionLifetime = TimeSpan.FromMinutes(2) }) + { + BaseAddress = Server, + Timeout = Timeout.InfiniteTimeSpan, + }; + _http.DefaultRequestHeaders.UserAgent.ParseAdd("Sunfish/3.0"); + if (!string.IsNullOrEmpty(user)) + _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", + Convert.ToBase64String(Encoding.UTF8.GetBytes(user + ":" + (password ?? "")))); + } + + public Uri Server { get; } + + /// "192.168.1.10:8080", "http://equipo:8080/fotos/" -> Uri del servidor y ruta inicial. + public static bool TryParseAddress(string text, out Uri server, out string path) + { + server = null!; + path = "/"; + text = text.Trim(); + if (text.Length == 0) + return false; + if (!text.Contains("://", StringComparison.Ordinal)) + text = "http://" + text; + if (!Uri.TryCreate(text, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https")) + return false; + server = new Uri(uri.GetLeftPart(UriPartial.Authority) + "/"); + path = Uri.UnescapeDataString(uri.AbsolutePath); + return true; + } + + public static string Combine(string folder, string name) => folder.TrimEnd('/') + "/" + name; + + public static string Parent(string path) + { + string trimmed = path.TrimEnd('/'); + int cut = trimmed.LastIndexOf('/'); + return cut <= 0 ? "/" : trimmed[..cut]; + } + + private static string Encode(string path, bool collection) + { + var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries).Select(Uri.EscapeDataString); + string encoded = "/" + string.Join('/', parts); + return collection && encoded != "/" ? encoded + "/" : encoded; + } + + /// Contenido de una carpeta (sin la propia carpeta), carpetas primero. + public async Task> ListAsync(string folder, CancellationToken ct = default) + { + const string body = "" + + ""; + using var request = new HttpRequestMessage(PropFind, Encode(folder, collection: true)) + { + Content = new StringContent(body, Encoding.UTF8, "application/xml"), + }; + request.Headers.Add("Depth", "1"); + using var response = await _http.SendAsync(request, ct); + await EnsureAsync(response, 207); + var xml = await response.Content.ReadAsByteArrayAsync(ct); + string self = Encode(folder, collection: true); + return ParseMultiStatus(xml) + .Where(i => !SamePath(i.Path, folder)) + .OrderBy(i => i.IsCollection ? 0 : 1) + .ThenBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static bool SamePath(string a, string b) => + string.Equals(a.TrimEnd('/'), b.TrimEnd('/'), StringComparison.Ordinal); + + internal static List ParseMultiStatus(byte[] xml) + { + var items = new List(); + var settings = new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null, IgnoreWhitespace = true }; + using var reader = XmlReader.Create(new MemoryStream(xml), settings); + string? href = null; + bool collection = false; + long size = 0; + DateTime? modified = null; + string? current = null; + while (reader.Read()) + { + if (reader.NodeType == XmlNodeType.Element && reader.NamespaceURI == "DAV:") + { + switch (reader.LocalName) + { + case "response": + href = null; collection = false; size = 0; modified = null; + break; + case "collection": + collection = true; + break; + case "href" or "getcontentlength" or "getlastmodified": + current = reader.LocalName; + break; + } + } + else if (reader.NodeType == XmlNodeType.Text && current is not null) + { + switch (current) + { + case "href": href ??= reader.Value.Trim(); break; + case "getcontentlength": + long.TryParse(reader.Value.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out size); + break; + case "getlastmodified": + if (DateTime.TryParse(reader.Value.Trim(), CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var t)) + modified = t.ToLocalTime(); + break; + } + current = null; + } + else if (reader.NodeType == XmlNodeType.EndElement && reader.NamespaceURI == "DAV:" && reader.LocalName == "response" && href is not null) + { + string path = HrefToPath(href); + string name = path.TrimEnd('/'); + name = name[(name.LastIndexOf('/') + 1)..]; + items.Add(new DavItem(name, path.Length > 1 ? path.TrimEnd('/') : "/", collection, size, modified)); + } + } + return items; + } + + /// href absoluto o relativo -> ruta decodificada. + private static string HrefToPath(string href) + { + if (Uri.TryCreate(href, UriKind.Absolute, out var absolute) && absolute.Scheme.StartsWith("http", StringComparison.Ordinal)) + href = absolute.AbsolutePath; + return PathRules.PercentDecode(href); + } + + public async Task DownloadAsync(string path, Stream destination, IProgress? progress = null, CancellationToken ct = default) + { + using var response = await _http.GetAsync(Encode(path, false), HttpCompletionOption.ResponseHeadersRead, ct); + await EnsureAsync(response, 200); + await using var source = await response.Content.ReadAsStreamAsync(ct); + await CopyAsync(source, destination, progress, ct); + } + + public async Task UploadAsync(string path, Stream source, IProgress? progress = null, CancellationToken ct = default) + { + using var content = new ProgressContent(source, progress); + using var response = await _http.PutAsync(Encode(path, false), content, ct); + await EnsureAsync(response, 201, 204, 200); + } + + public async Task CreateFolderAsync(string path, CancellationToken ct = default) + { + using var response = await _http.SendAsync(new HttpRequestMessage(MkCol, Encode(path, true)), ct); + await EnsureAsync(response, 201, 405); // 405: ya existe + } + + public async Task DeleteAsync(string path, bool collection, CancellationToken ct = default) + { + using var response = await _http.DeleteAsync(Encode(path, collection), ct); + await EnsureAsync(response, 204, 200); + } + + public async Task MoveAsync(string from, string to, bool collection, CancellationToken ct = default) + { + using var request = new HttpRequestMessage(Move, Encode(from, collection)); + request.Headers.Add("Destination", new Uri(Server, Encode(to, collection)).ToString()); + request.Headers.Add("Overwrite", "F"); + using var response = await _http.SendAsync(request, ct); + await EnsureAsync(response, 201, 204); + } + + /// Descarga un fichero o una carpeta entera a una carpeta local. + public async Task DownloadTreeAsync(DavItem item, string localFolder, IProgress? progress = null, CancellationToken ct = default) + { + string target = Path.Combine(localFolder, SafeLocalName(item.Name)); + if (!item.IsCollection) + { + string temp = target + ".part"; + await using (var file = File.Create(temp)) + await DownloadAsync(item.Path, file, progress, ct); + File.Move(temp, target, overwrite: true); + return; + } + Directory.CreateDirectory(target); + foreach (var child in await ListAsync(item.Path, ct)) + await DownloadTreeAsync(child, target, progress, ct); + } + + /// Sube un fichero o una carpeta local entera a una carpeta remota. + public async Task UploadTreeAsync(string localPath, string remoteFolder, IProgress? progress = null, CancellationToken ct = default) + { + string name = Path.GetFileName(Path.TrimEndingDirectorySeparator(localPath)); + string remote = Combine(remoteFolder, name); + if (File.Exists(localPath)) + { + await using var file = File.OpenRead(localPath); + await UploadAsync(remote, file, progress, ct); + return; + } + await CreateFolderAsync(remote, ct); + foreach (var entry in Directory.EnumerateFileSystemEntries(localPath)) + { + if (Path.GetFileName(entry).StartsWith('.')) + continue; + await UploadTreeAsync(entry, remote, progress, ct); + } + } + + /// Un nombre remoto que se pueda guardar en este sistema. + private static string SafeLocalName(string name) + { + var invalid = Path.GetInvalidFileNameChars(); + var clean = new string(name.Select(c => invalid.Contains(c) || c == ':' ? '_' : c).ToArray()).TrimEnd('.', ' '); + return clean.Length == 0 || clean is "." or ".." ? "_" : clean; + } + + private static async Task CopyAsync(Stream source, Stream destination, IProgress? progress, CancellationToken ct) + { + var buffer = new byte[65536]; + int n; + while ((n = await source.ReadAsync(buffer, ct)) > 0) + { + await destination.WriteAsync(buffer.AsMemory(0, n), ct); + progress?.Report(n); + } + } + + private static async Task EnsureAsync(HttpResponseMessage response, params int[] ok) + { + int status = (int)response.StatusCode; + if (ok.Contains(status)) + return; + string text = ""; + try + { + text = await response.Content.ReadAsStringAsync(); + if (text.Length > 200 || text.TrimStart().StartsWith('<')) + text = ""; + } + catch (HttpRequestException) { } + throw new DavException(status, $"{status} {response.ReasonPhrase} {text}".Trim()); + } + + public void Dispose() => _http.Dispose(); + + /// Cuerpo de PUT que informa del progreso. + private sealed class ProgressContent(Stream source, IProgress? progress) : HttpContent + { + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context) => + await CopyAsync(source, stream, progress, CancellationToken.None); + + protected override bool TryComputeLength(out long length) + { + if (source.CanSeek) + { + length = source.Length - source.Position; + return true; + } + length = 0; + return false; + } + } +} diff --git a/src/Sunfish.Core/Encodings.cs b/src/Sunfish.Core/Encodings.cs new file mode 100644 index 0000000..903baf8 --- /dev/null +++ b/src/Sunfish.Core/Encodings.cs @@ -0,0 +1,27 @@ +using System.Text; + +namespace Sunfish; + +/// Juegos de caracteres antiguos (CP437, MacRoman...) a traves de CodePagesEncodingProvider. +public static class Encodings +{ + // Los inicializadores estaticos se ejecutan en orden: el proveedor se registra antes de pedir nada. + private static readonly bool Registered = Register(); + + /// CP437 que falla si un caracter no se puede representar. + public static Encoding Cp437Strict { get; } = + Encoding.GetEncoding(437, EncoderFallback.ExceptionFallback, DecoderFallback.ReplacementFallback); + + /// MacRoman (nombres de fichero de Mac clasico); lo no representable pasa a '_'. + public static Encoding MacRoman { get; } = + Encoding.GetEncoding(10000, new EncoderReplacementFallback("_"), new DecoderReplacementFallback("_")); + + private static bool Register() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + return true; + } + + /// Asegura el registro del proveedor (para quien use Encoding.GetEncoding directamente). + public static bool EnsureRegistered() => Registered; +} diff --git a/src/Sunfish.Core/Ftp/DataChannel.cs b/src/Sunfish.Core/Ftp/DataChannel.cs new file mode 100644 index 0000000..b700d7d --- /dev/null +++ b/src/Sunfish.Core/Ftp/DataChannel.cs @@ -0,0 +1,160 @@ +using System.Globalization; +using System.Net; +using System.Net.Sockets; +using Sunfish.Http; + +namespace Sunfish.Ftp; + +/// +/// Conexion de datos de una sesion FTP, en modo pasivo (el cliente se conecta a un puerto que +/// abre el servidor) o activo (el servidor se conecta al cliente). Por seguridad solo se aceptan +/// conexiones de, o hacia, la misma IP que abrio el control (evita el "FTP bounce" y el robo de +/// la conexion de datos), y en activo nunca a puertos por debajo de 1024. +/// +internal sealed class DataChannel : IDisposable +{ + public static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(30); + + private readonly IPAddress _client; + private Socket? _passive; + private IPEndPoint? _active; + + public DataChannel(IPAddress client) => _client = Normalize(client); + + public bool Ready => _passive is not null || _active is not null; + + /// Abre un puerto pasivo en la direccion local indicada; devuelve el puerto. + public int OpenPassive(IPAddress local, string range) + { + Reset(); + var (first, last) = ParseRange(range); + List candidates = first == 0 + ? [0] + : Enumerable.Range(first, last - first + 1).OrderBy(_ => Random.Shared.Next()).ToList(); + foreach (int port in candidates) + { + var socket = new Socket(local.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + try + { + socket.Bind(new IPEndPoint(local, port)); + socket.Listen(1); + _passive = socket; + return ((IPEndPoint)socket.LocalEndPoint!).Port; + } + catch (SocketException) + { + socket.Dispose(); + } + } + throw new SocketException((int)SocketError.AddressAlreadyInUse); + } + + /// PORT/EPRT: guarda el destino si es la IP del cliente y un puerto no privilegiado. + public bool SetActive(IPEndPoint endpoint) + { + if (!Normalize(endpoint.Address).Equals(_client) || endpoint.Port < 1024) + return false; + Reset(); + _active = endpoint; + return true; + } + + /// Establece la conexion de datos preparada (y la consume). + public async Task ConnectAsync(CancellationToken ct) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(ConnectTimeout); + try + { + if (_passive is { } listener) + { + while (true) + { + var socket = await listener.AcceptAsync(timeout.Token); + if (Normalize(((IPEndPoint)socket.RemoteEndPoint!).Address).Equals(_client)) + return Wrap(socket, ct); + socket.Dispose(); // otra IP: se ignora y se sigue esperando al cliente + } + } + if (_active is { } target) + { + var socket = new Socket(target.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + try + { + await socket.ConnectAsync(target, timeout.Token); + return Wrap(socket, ct); + } + catch + { + socket.Dispose(); + throw; + } + } + throw new InvalidOperationException("No data connection prepared"); + } + finally + { + Reset(); + } + } + + private static Stream Wrap(Socket socket, CancellationToken ct) + { + socket.NoDelay = true; + return new TimeoutStream(new NetworkStream(socket, ownsSocket: true), ct) + { + ReadLimit = TimeSpan.FromSeconds(120), + WriteLimit = TimeSpan.FromSeconds(120), + }; + } + + public void Reset() + { + _passive?.Dispose(); + _passive = null; + _active = null; + } + + public void Dispose() => Reset(); + + internal static IPAddress Normalize(IPAddress address) => + address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address; + + /// "50000-50050" -> (50000, 50050); vacio o invalido -> (0, 0): cualquier puerto libre. + internal static (int First, int Last) ParseRange(string range) + { + var parts = range.Split('-', StringSplitOptions.TrimEntries); + if (parts.Length == 2 && + int.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out int a) && + int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out int b) && + a is >= 1024 and <= 65535 && b >= a && b <= 65535) + return (a, b); + return (0, 0); + } + + /// "h1,h2,h3,h4,p1,p2" de PORT. + internal static IPEndPoint? ParsePort(string argument) + { + var n = argument.Split(',', StringSplitOptions.TrimEntries); + if (n.Length != 6) + return null; + var bytes = new byte[6]; + for (int i = 0; i < 6; i++) + if (!byte.TryParse(n[i], NumberStyles.None, CultureInfo.InvariantCulture, out bytes[i])) + return null; + return new IPEndPoint(new IPAddress(bytes[..4]), bytes[4] * 256 + bytes[5]); + } + + /// "|1|132.235.1.2|6275|" o "|2|::1|6275|" de EPRT. + internal static IPEndPoint? ParseEprt(string argument) + { + if (argument.Length < 5) + return null; + char d = argument[0]; + var parts = argument.Split(d); + if (parts.Length != 5 || !IPAddress.TryParse(parts[2], out var address) || + !int.TryParse(parts[3], NumberStyles.None, CultureInfo.InvariantCulture, out int port) || port is < 1 or > 65535) + return null; + return new IPEndPoint(address, port); + } +} diff --git a/src/Sunfish.Core/Ftp/FtpListing.cs b/src/Sunfish.Core/Ftp/FtpListing.cs new file mode 100644 index 0000000..d976ffb --- /dev/null +++ b/src/Sunfish.Core/Ftp/FtpListing.cs @@ -0,0 +1,66 @@ +using System.Globalization; +using Sunfish.Config; +using Sunfish.Shares; + +namespace Sunfish.Ftp; + +/// +/// Formatos de listado. LIST usa el de "ls -l" de Unix, que es el que entienden los clientes +/// antiguos (clientes graficos de Mac y Windows de los 90, navegadores con ftp://). MLSD y MLST +/// siguen RFC 3659 para los modernos. +/// +internal static class FtpListing +{ + private static readonly string[] Months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + + /// "drwxr-xr-x 1 ftp ftp 0 Nov 04 13:05 nombre" (o el ano si tiene mas de 6 meses). + public static string UnixLine(FileEntry e, DateTime now) + { + string mode = e.IsDirectory ? "drwxr-xr-x" : "-rw-r--r--"; + var t = e.IsVirtual ? now : e.Modified; + string when = Math.Abs((now - t).TotalDays) < 180 + ? $"{Months[t.Month - 1]} {t.Day,2} {t:HH':'mm}" + : $"{Months[t.Month - 1]} {t.Day,2} {t.Year}"; + string size = (e.IsDirectory ? 0 : e.Size).ToString(CultureInfo.InvariantCulture); + return $"{mode} 1 ftp ftp {size,12} {when} {e.Name}"; + } + + /// Hechos de MLSD/MLST: "type=file;size=12;modify=20261104130500;perm=rwdf; nombre". + public static string Facts(FileEntry e, string type, ShareConfig? share, bool readOnly) + { + string perm = Permissions(e.IsDirectory, share, readOnly || e.IsVirtual); + var facts = $"type={type};"; + if (!e.IsDirectory) + facts += $"size={e.Size.ToString(CultureInfo.InvariantCulture)};"; + if (!e.IsVirtual) + facts += $"modify={Timestamp(e.Modified)};"; + return facts + $"perm={perm}; {e.Name}"; + } + + /// Hora en UTC como la piden MDTM y MLSD: yyyyMMddHHmmss. + public static string Timestamp(DateTime local) => + local.ToUniversalTime().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture); + + private static string Permissions(bool directory, ShareConfig? share, bool readOnly) + { + if (directory) + { + string p = "e" + (share is null || share.Listing ? "l" : ""); + if (share is not null && !readOnly) + { + if (share.Upload) p += "cm"; + if (share.Delete) p += "dp"; + if (share.Rename) p += "f"; + } + return p; + } + string f = "r"; + if (share is not null && !readOnly) + { + if (share.Overwrite) f += "wa"; + if (share.Delete) f += "d"; + if (share.Rename) f += "f"; + } + return f; + } +} diff --git a/src/Sunfish.Core/Ftp/FtpPath.cs b/src/Sunfish.Core/Ftp/FtpPath.cs new file mode 100644 index 0000000..f79ec79 --- /dev/null +++ b/src/Sunfish.Core/Ftp/FtpPath.cs @@ -0,0 +1,40 @@ +using Sunfish.Shares; + +namespace Sunfish.Ftp; + +/// Rutas de FTP: relativas a la carpeta actual o absolutas, con "." y "..". +internal static class FtpPath +{ + /// + /// Combina la carpeta actual con el argumento de un comando. Acepta '/' y '\' como + /// separador (algunos clientes de DOS y Windows usan '\'). ".." por encima de la raiz se + /// queda en la raiz. Devuelve null si algun componente no es valido. + /// + public static List? Resolve(IReadOnlyList cwd, string? argument) + { + var result = new List(); + string arg = argument ?? ""; + if (!(arg.StartsWith('/') || arg.StartsWith('\\'))) + result.AddRange(cwd); + foreach (var part in arg.Split('/', '\\')) + { + if (part.Length == 0 || part == ".") + continue; + if (part == "..") + { + if (result.Count > 0) + result.RemoveAt(result.Count - 1); + continue; + } + if (!PathRules.IsValidSegment(part)) + return null; + result.Add(part); + } + return result; + } + + public static string Format(IReadOnlyList segments) => "/" + string.Join('/', segments); + + /// Comillas dobladas, como pide RFC 959 para la respuesta 257. + public static string Quote(string path) => "\"" + path.Replace("\"", "\"\"") + "\""; +} diff --git a/src/Sunfish.Core/Ftp/FtpServer.cs b/src/Sunfish.Core/Ftp/FtpServer.cs new file mode 100644 index 0000000..8099109 --- /dev/null +++ b/src/Sunfish.Core/Ftp/FtpServer.cs @@ -0,0 +1,126 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; +using Sunfish.Auth; +using Sunfish.Http; +using Sunfish.Log; +using Sunfish.Web; + +namespace Sunfish.Ftp; + +/// +/// Servidor FTP sobre el mismo arbol de shares que la web. Cada conexion de control es una +/// . Sin cifrado, a proposito (ver DOC/SEGURIDAD.md). +/// +public sealed class FtpServer : IAsyncDisposable +{ + public const int MaxConnectionsPerAddress = 16; + + private readonly Func _state; + private readonly ServerLog _log; + private readonly Authenticator _auth; + private readonly List _listeners = new(); + private readonly List _acceptLoops = new(); + private readonly ConcurrentDictionary _sessions = new(); + private readonly ConcurrentDictionary _perAddress = new(); + private readonly CancellationTokenSource _shutdown = new(); + + public FtpServer(Func state, ServerLog log, Authenticator auth) + { + _state = state; + _log = log; + _auth = auth; + } + + public IReadOnlyList Endpoints => _listeners.Select(l => (IPEndPoint)l.LocalEndPoint!).ToList(); + + /// Abre los sockets de control. Lanza si el puerto esta ocupado. + public void Start() + { + var server = _state().Config.Server; + foreach (var (address, dualMode) in HttpServer.PlanSockets(server.Bind)) + { + var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + try + { + if (address.AddressFamily == AddressFamily.InterNetworkV6) + socket.DualMode = dualMode; + if (OperatingSystem.IsWindows()) + socket.ExclusiveAddressUse = true; + else + socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + socket.Bind(new IPEndPoint(address, server.FtpPort)); + socket.Listen(64); + } + catch + { + socket.Dispose(); + foreach (var opened in _listeners) + opened.Dispose(); + _listeners.Clear(); + throw; + } + _listeners.Add(socket); + } + foreach (var listener in _listeners) + { + _log.Info(Strings.FtpListening(listener.LocalEndPoint!.ToString()!)); + _acceptLoops.Add(AcceptLoopAsync(listener)); + } + } + + private async Task AcceptLoopAsync(Socket listener) + { + while (!_shutdown.IsCancellationRequested) + { + Socket client; + try + { + client = await listener.AcceptAsync(_shutdown.Token); + } + catch (OperationCanceledException) { break; } + catch (ObjectDisposedException) { break; } + catch (SocketException) { continue; } + + var address = DataChannel.Normalize(((IPEndPoint)client.RemoteEndPoint!).Address); + if (_perAddress.AddOrUpdate(address, 1, (_, c) => c + 1) > MaxConnectionsPerAddress) + { + Release(address); + client.Dispose(); + continue; + } + var session = new FtpSession(client, _state, _log, _auth, _shutdown.Token); + var task = session.RunAsync(); + _sessions.TryAdd(task, 0); + _ = task.ContinueWith(t => + { + _sessions.TryRemove(t, out _); + Release(address); + }, TaskScheduler.Default); + } + } + + private void Release(IPAddress address) => + _perAddress.AddOrUpdate(address, 0, (_, c) => Math.Max(0, c - 1)); + + public async Task StopAsync() + { + if (_shutdown.IsCancellationRequested) + return; + _shutdown.Cancel(); + foreach (var listener in _listeners) + listener.Dispose(); + try + { + await Task.WhenAll(_acceptLoops.Concat(_sessions.Keys)).WaitAsync(TimeSpan.FromSeconds(5)); + } + catch (Exception e) when (e is TimeoutException or OperationCanceledException) { } + _log.Info(Strings.FtpStopped); + } + + public async ValueTask DisposeAsync() + { + await StopAsync(); + _shutdown.Dispose(); + } +} diff --git a/src/Sunfish.Core/Ftp/FtpSession.cs b/src/Sunfish.Core/Ftp/FtpSession.cs new file mode 100644 index 0000000..d38aaf1 --- /dev/null +++ b/src/Sunfish.Core/Ftp/FtpSession.cs @@ -0,0 +1,967 @@ +using System.Globalization; +using System.Net; +using System.Net.Sockets; +using System.Text; +using Sunfish.Auth; +using Sunfish.Config; +using Sunfish.Http; +using Sunfish.Log; +using Sunfish.Shares; +using Sunfish.Web; + +namespace Sunfish.Ftp; + +/// +/// Una conexion de control FTP. Sirve el mismo arbol de shares que la web: el usuario anonimo +/// ve los shares publicos y un usuario con contrasena, ademas, los que lo incluyen en Users. +/// Los permisos son los del share. Mientras dura una transferencia se sigue leyendo el control +/// para atender ABOR. +/// +internal sealed class FtpSession +{ + private const int MaxLine = 4096; + + private readonly Socket _socket; + private readonly Func _state; + private readonly ServerLog _log; + private readonly Authenticator _auth; + private readonly CancellationToken _shutdown; + private readonly IPEndPoint _remote; + private readonly IPEndPoint _local; + private readonly DataChannel _data; + private Stream _control = Stream.Null; + private readonly byte[] _buffer = new byte[4096]; + private int _pos, _len; + private Task? _pendingLine; + + private string? _pendingUser; + private string? _user; // null = sin identificar; "" = anonimo + private ClientKind? _profile; + private bool _utf8; + private Encoding _legacy = Encoding.Latin1; + private List _cwd = new(); + private bool _ascii; + private long _restart; + private List? _renameFrom; + + public FtpSession(Socket socket, Func state, ServerLog log, Authenticator auth, CancellationToken shutdown) + { + _socket = socket; + _state = state; + _log = log; + _auth = auth; + _shutdown = shutdown; + _remote = (IPEndPoint)socket.RemoteEndPoint!; + _local = (IPEndPoint)socket.LocalEndPoint!; + _data = new DataChannel(_remote.Address); + } + + private IPAddress RemoteAddress => DataChannel.Normalize(_remote.Address); + + public async Task RunAsync() + { + _socket.NoDelay = true; + _control = new TimeoutStream(new NetworkStream(_socket, ownsSocket: true), _shutdown) + { + ReadLimit = TimeSpan.FromMinutes(5), + WriteLimit = TimeSpan.FromSeconds(60), + }; + _legacy = LegacyEncoding(_state().Config.Server.FtpCharset); + try + { + await ReplyAsync(220, $"{_state().Config.Server.Name} - Sunfish 3.0 FTP"); + while (!_shutdown.IsCancellationRequested) + { + string? line = await (_pendingLine ?? ReadLineAsync()); + _pendingLine = null; + if (line is null) + break; + if (!await HandleAsync(line)) + break; + } + } + catch (Exception e) when (e is IOException or SocketException or OperationCanceledException or ObjectDisposedException) + { + _log.Detail(Strings.ConnectionError(RemoteAddress.ToString(), e.Message)); + } + catch (Exception e) + { + _log.Error(Strings.ConnectionError(RemoteAddress.ToString(), e.Message)); + } + finally + { + _data.Dispose(); + await _control.DisposeAsync(); + } + } + + // ------------------------------------------------------------------ control + + /// + /// Una linea de control, sin CR/LF y sin secuencias Telnet (IAC). Se decodifica como UTF-8 si + /// es valido y, si no, con el juego de caracteres de la sesion. + /// + private async Task ReadLineAsync() + { + var bytes = new List(); + while (true) + { + if (_pos >= _len) + { + _len = await _control.ReadAsync(_buffer, _shutdown); + _pos = 0; + if (_len == 0) + return bytes.Count > 0 ? Decode(bytes.ToArray()) : null; + } + byte b = _buffer[_pos++]; + if (b == 0xFF) + { + // IAC: los clientes antiguos envian IAC IP / IAC DM antes de ABOR. + if (_pos >= _len) + { + _len = await _control.ReadAsync(_buffer, _shutdown); + _pos = 0; + if (_len == 0) + return null; + } + byte cmd = _buffer[_pos++]; + if (cmd == 0xFF) + bytes.Add(0xFF); + else if (cmd is >= 251 and <= 254 && _pos < _len) + _pos++; // WILL/WONT/DO/DONT llevan una opcion + continue; + } + if (b == '\n') + break; + bytes.Add(b); + if (bytes.Count > MaxLine) + throw new IOException("Command line too long"); + } + if (bytes.Count > 0 && bytes[^1] == '\r') + bytes.RemoveAt(bytes.Count - 1); + return Decode(bytes.ToArray()); + } + + private string Decode(byte[] bytes) + { + try + { + return new UTF8Encoding(false, true).GetString(bytes); + } + catch (DecoderFallbackException) + { + return _legacy.GetString(bytes); + } + } + + private Encoding NamesEncoding => _utf8 ? new UTF8Encoding(false) : _legacy; + + private Task ReplyAsync(int code, string text) => SendAsync($"{code} {text}\r\n"); + + private Task ReplyMultiAsync(int code, IEnumerable lines, string end) + { + var sb = new StringBuilder(); + sb.Append(code).Append('-').Append(lines.FirstOrDefault() ?? "").Append("\r\n"); + foreach (var line in lines.Skip(1)) + sb.Append(' ').Append(line).Append("\r\n"); + sb.Append(code).Append(' ').Append(end).Append("\r\n"); + return SendAsync(sb.ToString()); + } + + private async Task SendAsync(string text) => + await _control.WriteAsync(NamesEncoding.GetBytes(text), _shutdown); + + /// Devuelve false para cerrar la sesion. + private async Task HandleAsync(string line) + { + int space = line.IndexOf(' '); + string command = (space < 0 ? line : line[..space]).Trim().ToUpperInvariant(); + string argument = space < 0 ? "" : line[(space + 1)..]; + _log.Detail($"{RemoteAddress} FTP {(command == "PASS" ? "PASS ***" : line)}"); + + switch (command) + { + case "QUIT": + await ReplyAsync(221, "Bye"); + return false; + case "USER": await UserAsync(argument.Trim()); return true; + case "PASS": await PassAsync(argument); return true; + case "NOOP": await ReplyAsync(200, "OK"); return true; + case "SYST": await ReplyAsync(215, "UNIX Type: L8"); return true; + case "FEAT": await FeatAsync(); return true; + case "OPTS": await OptsAsync(argument); return true; + case "HELP": await HelpAsync(); return true; + case "CLNT": await ReplyAsync(200, "OK"); return true; + case "AUTH" or "PBSZ" or "PROT" or "CCC": + await ReplyAsync(502, "TLS not available"); + return true; + } + + if (_user is null) + { + await ReplyAsync(530, "Please login with USER and PASS"); + return true; + } + + switch (command) + { + case "PWD" or "XPWD": await ReplyAsync(257, FtpPath.Quote(FtpPath.Format(_cwd)) + " is the current directory"); break; + case "CWD" or "XCWD": await CwdAsync(argument); break; + case "CDUP" or "XCUP": await CwdAsync(".."); break; + case "TYPE": await TypeAsync(argument); break; + case "MODE": await ReplyAsync(argument.Trim().Equals("S", StringComparison.OrdinalIgnoreCase) ? 200 : 504, "Mode"); break; + case "STRU": await ReplyAsync(argument.Trim().Equals("F", StringComparison.OrdinalIgnoreCase) ? 200 : 504, "Structure"); break; + case "ALLO": await ReplyAsync(202, "No storage allocation necessary"); break; + case "PASV": await PasvAsync(extended: false); break; + case "EPSV": await PasvAsync(extended: true); break; + case "PORT": await PortAsync(DataChannel.ParsePort(argument)); break; + case "EPRT": await PortAsync(DataChannel.ParseEprt(argument.Trim())); break; + case "REST": await RestAsync(argument); break; + case "LIST": await ListAsync(argument, ListKind.Unix); break; + case "NLST": await ListAsync(argument, ListKind.Names); break; + case "MLSD": await ListAsync(argument, ListKind.Mlsd); break; + case "MLST": await MlstAsync(argument); break; + case "RETR": await RetrAsync(argument); break; + case "STOR": await StorAsync(argument, append: false); break; + case "APPE": await StorAsync(argument, append: true); break; + case "SIZE": await SizeAsync(argument); break; + case "MDTM": await MdtmAsync(argument); break; + case "MKD" or "XMKD": await MkdAsync(argument); break; + case "RMD" or "XRMD": await RmdAsync(argument); break; + case "DELE": await DeleAsync(argument); break; + case "RNFR": await RnfrAsync(argument); break; + case "RNTO": await RntoAsync(argument); break; + case "SITE": await SiteAsync(argument); break; + case "STAT": await StatAsync(); break; + case "ABOR": await ReplyAsync(226, "No transfer to abort"); break; + default: await ReplyAsync(502, "Command not implemented"); break; + } + return true; + } + + // ------------------------------------------------------------------ sesion + + private async Task UserAsync(string name) + { + // "usuario+dos", "anonymous+mac"...: perfil de cliente para esta sesion (DOC/CLIENTES.md). + _profile = null; + int plus = name.LastIndexOf('+'); + if (plus > 0 && ClientProfile.TryParseCode(name[(plus + 1)..], out var kind)) + { + _profile = kind; + name = name[..plus]; + } + _pendingUser = name; + _user = null; + ApplyProfileCharset(); + if (IsAnonymous(name)) + await ReplyAsync(331, "Anonymous login: send any password"); + else + await ReplyAsync(331, "Password required"); + } + + private static bool IsAnonymous(string name) => + name.Equals("anonymous", StringComparison.OrdinalIgnoreCase) || name.Equals("ftp", StringComparison.OrdinalIgnoreCase); + + private async Task PassAsync(string password) + { + if (_pendingUser is null) + { + await ReplyAsync(503, "Send USER first"); + return; + } + string name = _pendingUser; + _pendingUser = null; + if (IsAnonymous(name)) + { + _user = ""; + _log.Info($"{RemoteAddress} FTP login anonymous"); + await ReplyAsync(230, "Logged in (anonymous)"); + return; + } + var config = _state().Config; + var result = _auth.Check(config.Users, config.Users.Select(u => u.Name).ToList(), name, password, RemoteAddress); + switch (result) + { + case AuthResult.Ok: + _user = config.Users.First(u => u.Name.Equals(name, StringComparison.OrdinalIgnoreCase)).Name; + _log.Info($"{RemoteAddress} FTP login {_user}"); + await ReplyAsync(230, "Logged in"); + break; + case AuthResult.Blocked: + await ReplyAsync(421, "Too many failed attempts, try again later"); + throw new IOException("Blocked"); + default: + _log.Warning($"{RemoteAddress}: FTP auth failed for '{name}'"); + await Task.Delay(1000, _shutdown); + await ReplyAsync(530, "Login incorrect"); + break; + } + } + + private void ApplyProfileCharset() + { + _legacy = _profile switch + { + ClientKind.Dos => LegacyEncoding("cp437"), + ClientKind.Win16 => LegacyEncoding("windows-1252"), + ClientKind.Mac => LegacyEncoding("macroman"), + _ => LegacyEncoding(_state().Config.Server.FtpCharset), + }; + } + + internal static Encoding LegacyEncoding(string name) + { + Encodings.EnsureRegistered(); + int codePage = name.Trim().ToLowerInvariant() switch + { + "macroman" or "macintosh" => 10000, + "cp437" or "ibm437" => 437, + "cp850" or "ibm850" => 850, + "windows-1252" or "cp1252" => 1252, + "utf-8" or "utf8" => 65001, + _ => 28591, // iso-8859-1 + }; + return Encoding.GetEncoding(codePage, new EncoderReplacementFallback("?"), new DecoderReplacementFallback("?")); + } + + private Task FeatAsync() => ReplyMultiAsync(211, + [ + "Features:", "UTF8", "MLST type*;size*;modify*;perm*;", "SIZE", "MDTM", "REST STREAM", "EPSV", "EPRT", "PASV", + ], "End"); + + private async Task OptsAsync(string argument) + { + var parts = argument.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 1 && parts[0].Equals("UTF8", StringComparison.OrdinalIgnoreCase)) + { + _utf8 = parts.Length < 2 || parts[1].Equals("ON", StringComparison.OrdinalIgnoreCase); + await ReplyAsync(200, _utf8 ? "UTF8 mode on" : "UTF8 mode off"); + return; + } + if (parts.Length >= 1 && parts[0].Equals("MLST", StringComparison.OrdinalIgnoreCase)) + { + await ReplyAsync(200, "MLST OPTS type;size;modify;perm;"); + return; + } + await ReplyAsync(501, "Option not understood"); + } + + private Task HelpAsync() => ReplyMultiAsync(214, + [ + "Commands:", + "USER PASS QUIT NOOP SYST FEAT OPTS HELP STAT SITE", + "PWD CWD CDUP TYPE MODE STRU PASV EPSV PORT EPRT REST", + "LIST NLST MLSD MLST RETR STOR APPE SIZE MDTM", + "MKD RMD DELE RNFR RNTO ABOR", + "SITE PROFILE dos|win16|mac|none (or USER name+dos)", + ], "OK"); + + private async Task SiteAsync(string argument) + { + var parts = argument.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 2 && parts[0].Equals("PROFILE", StringComparison.OrdinalIgnoreCase)) + { + if (parts[1].Equals("none", StringComparison.OrdinalIgnoreCase)) + _profile = null; + else if (ClientProfile.TryParseCode(parts[1], out var kind)) + _profile = kind; + else + { + await ReplyAsync(501, "Unknown profile"); + return; + } + ApplyProfileCharset(); + await ReplyAsync(200, "Profile " + (_profile?.ToString().ToLowerInvariant() ?? "none")); + return; + } + await ReplyAsync(214, "SITE PROFILE dos|win16|mac|none"); + } + + private async Task StatAsync() + { + string who = string.IsNullOrEmpty(_user) ? "anonymous" : _user; + await ReplyMultiAsync(211, + [ + "Sunfish 3.0 FTP status:", + $"Connected from {RemoteAddress}", + $"Logged in as {who}", + $"TYPE: {(_ascii ? "ASCII" : "BINARY")}, names: {(_utf8 ? "UTF-8" : _legacy.WebName)}", + ], "End of status"); + } + + private async Task TypeAsync(string argument) + { + string t = argument.Trim().ToUpperInvariant(); + if (t.StartsWith('A')) + _ascii = true; + else if (t.StartsWith('I') || t.StartsWith("L")) + _ascii = false; + else + { + await ReplyAsync(504, "Type not supported"); + return; + } + await ReplyAsync(200, "Type set to " + (_ascii ? "A" : "I")); + } + + private async Task RestAsync(string argument) + { + if (!long.TryParse(argument.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out long offset)) + { + await ReplyAsync(501, "Invalid offset"); + return; + } + _restart = offset; + await ReplyAsync(350, $"Restarting at {offset}"); + } + + // ------------------------------------------------------------------ arbol de shares + + /// Un nodo del arbol visto por esta sesion. + /// "nombre.bin" que un Mac clasico ve por un fichero con AppleDouble: se sirve en MacBinary. + private sealed record Node(List Segments, ShareConfig? Share, IReadOnlyList Relative, + string? Physical, IReadOnlyList VirtualChildren, bool MacBin = false) + { + public bool IsFile => Physical is not null && File.Exists(Physical); + public bool IsRealDirectory => Physical is not null && Directory.Exists(Physical); + public bool IsDirectory => IsRealDirectory || VirtualChildren.Count > 0 || Segments.Count == 0; + public bool Exists => IsFile || IsDirectory; + public bool Writable => Share is not null && IsRealDirectory; + } + + /// Shares que ve esta sesion: activos, con FTP y publicos o con este usuario. + private ShareTree Tree() + { + var shares = _state().Config.Shares.Where(s => + s.Enabled && s.Ftp && + (s.Users.Count == 0 || (!string.IsNullOrEmpty(_user) && s.Users.Contains(_user, StringComparer.OrdinalIgnoreCase)))); + return new ShareTree(shares); + } + + private Node? Resolve(string? argument) + { + var segments = FtpPath.Resolve(_cwd, argument); + if (segments is null) + return null; + var resolution = Tree().Resolve(segments); + var share = resolution.Share; + string? physical = share is null ? null : ShareFileSystem.TryGetPhysicalPath(share, resolution.Relative, aliases: share.ShortNames); + // Un Mac clasico ve "nombre.bin" por cada fichero con resource fork. + if (share is { Mac: true } && _profile == ClientKind.Mac && physical is not null && + !File.Exists(physical) && !Directory.Exists(physical) && + physical.EndsWith(".bin", StringComparison.OrdinalIgnoreCase) && + physical[..^4] is var data && File.Exists(data) && Mac.MacFiles.HasCompanion(data)) + return new Node(segments, share, resolution.Relative, data, resolution.VirtualChildren, MacBin: true); + return new Node(segments, share, resolution.Relative, physical, resolution.VirtualChildren); + } + + /// Como ven este cliente los nombres y tamanos: alias 8.3 (DOS, Windows 3.1) o .bin (Mac clasico). + private List Present(Node dir, List entries) + { + if (dir.Share is not { } share || !dir.IsRealDirectory) + return entries; + if (share.ShortNames && _profile is ClientKind.Dos or ClientKind.Win16) + { + var map = ShortNames.Map(new DirectoryInfo(dir.Physical!).EnumerateFileSystemInfos().Select(i => i.Name)); + return entries.Select(e => e.IsVirtual ? e : e with { Name = map.GetValueOrDefault(e.Name, e.Name) }).ToList(); + } + if (share.Mac && _profile == ClientKind.Mac) + return entries.Select(e => + { + string path = Path.Combine(dir.Physical!, e.Name); + return e.IsDirectory || e.IsVirtual || !Mac.MacFiles.HasCompanion(path) + ? e + : e with { Name = e.Name + ".bin", Size = MacBinLength(path) }; + }).ToList(); + return entries; + } + + private static long MacBinLength(string path) => + Mac.MacBinary.EncodedLength(new FileInfo(path).Length, Mac.MacFiles.Describe(path).ResourceFork.Length); + + /// Carpeta padre real y nombre del elemento para crear, renombrar o borrar. + private (Node Parent, string Name)? ParentOf(Node node) + { + if (node.Segments.Count == 0) + return null; + var parent = Resolve(FtpPath.Format(node.Segments.Take(node.Segments.Count - 1).ToList())); + if (parent is null || parent.Share is null || !parent.IsRealDirectory || parent.Share != node.Share) + return null; + string name = node.Segments[^1]; + if (parent.VirtualChildren.Contains(name, StringComparer.OrdinalIgnoreCase)) + return null; // una location mas especifica tapa este nombre + // Si se nombro por su alias 8.3 o como .bin, el nombre real es el del disco. + if (node.Physical is not null && (File.Exists(node.Physical) || Directory.Exists(node.Physical))) + name = Path.GetFileName(node.Physical); + return (parent, name); + } + + private List Children(Node dir) + { + var items = dir.VirtualChildren.Select(c => new FileEntry(c, true, 0, default, IsVirtual: true)).ToList(); + if (dir.IsRealDirectory) + items.AddRange(ShareFileSystem.List(dir.Physical!) + .Where(e => !dir.VirtualChildren.Contains(e.Name, StringComparer.OrdinalIgnoreCase))); + return items.OrderBy(e => e.IsDirectory ? 0 : 1).ThenBy(e => e.Name, StringComparer.OrdinalIgnoreCase).ToList(); + } + + private static FileEntry EntryOf(Node node) + { + string name = node.Segments.Count == 0 ? "/" : node.Segments[^1]; + if (node.IsFile) + { + var info = new FileInfo(node.Physical!); + return new FileEntry(name, false, node.MacBin ? MacBinLength(node.Physical!) : info.Length, info.LastWriteTime); + } + if (node.IsRealDirectory) + return new FileEntry(name, true, 0, Directory.GetLastWriteTime(node.Physical!)); + return new FileEntry(name, true, 0, default, IsVirtual: true); + } + + private async Task CwdAsync(string argument) + { + var node = Resolve(argument); + if (node is null || !node.IsDirectory) + { + await ReplyAsync(550, "No such directory"); + return; + } + _cwd = node.Segments; + await ReplyAsync(250, "Directory changed to " + FtpPath.Format(_cwd)); + } + + // ------------------------------------------------------------------ conexion de datos + + private async Task PasvAsync(bool extended) + { + var server = _state().Config.Server; + IPAddress local = DataChannel.Normalize(_local.Address); + if (!extended && local.AddressFamily != AddressFamily.InterNetwork) + { + await ReplyAsync(425, "Use EPSV with IPv6"); + return; + } + int port; + try + { + port = _data.OpenPassive(local, server.FtpPassivePorts); + } + catch (SocketException) + { + await ReplyAsync(425, "No passive port available"); + return; + } + if (extended) + { + await ReplyAsync(229, $"Entering Extended Passive Mode (|||{port}|)"); + return; + } + var announced = server.FtpPublicAddress ?? local; + var b = announced.GetAddressBytes(); + await ReplyAsync(227, $"Entering Passive Mode ({b[0]},{b[1]},{b[2]},{b[3]},{port / 256},{port % 256})"); + } + + private async Task PortAsync(IPEndPoint? endpoint) + { + if (endpoint is null || !_data.SetActive(endpoint)) + { + await ReplyAsync(500, "Illegal PORT command (only to your own address and ports >= 1024)"); + return; + } + await ReplyAsync(200, "PORT command successful"); + } + + /// + /// Abre la conexion de datos, ejecuta la transferencia y responde 226, o 426 si se aborta + /// (con ABOR, que se atiende mientras tanto) o falla. Devuelve si termino bien. + /// + private async Task TransferAsync(string what, Func work) + { + if (!_data.Ready) + { + await ReplyAsync(425, "Use PORT or PASV first"); + return false; + } + await ReplyAsync(150, "Opening data connection for " + what); + using var abort = CancellationTokenSource.CreateLinkedTokenSource(_shutdown); + var transfer = Task.Run(async () => + { + await using var stream = await _data.ConnectAsync(abort.Token); + await work(stream, abort.Token); + }); + + _pendingLine ??= ReadLineAsync(); + while (true) + { + var finished = await Task.WhenAny(transfer, _pendingLine); + if (finished == transfer) + break; + string? line = await _pendingLine; + if (line is not null && line.Trim().TrimStart('�').ToUpperInvariant().StartsWith("ABOR", StringComparison.Ordinal)) + { + _pendingLine = null; + abort.Cancel(); + try { await transfer; } catch { /* abortada */ } + await ReplyAsync(426, "Transfer aborted"); + await ReplyAsync(226, "ABOR command successful"); + return false; + } + // Otro comando a mitad de transferencia: se atiende cuando termine. + await transfer.ContinueWith(_ => { }, TaskScheduler.Default); + break; + } + try + { + await transfer; + await ReplyAsync(226, "Transfer complete"); + return true; + } + catch (Exception e) when (e is IOException or SocketException or OperationCanceledException or InvalidOperationException or UnauthorizedAccessException) + { + _log.Detail($"{RemoteAddress} FTP transfer failed: {e.Message}"); + await ReplyAsync(426, "Connection closed; transfer aborted"); + return false; + } + } + + // ------------------------------------------------------------------ listados + + private enum ListKind { Unix, Names, Mlsd } + + private async Task ListAsync(string argument, ListKind kind) + { + // "LIST -la carpeta": las opciones de ls se ignoran. + string path = string.Join(' ', argument.Split(' ', StringSplitOptions.RemoveEmptyEntries) + .SkipWhile(p => kind != ListKind.Mlsd && p.StartsWith('-'))); + var node = Resolve(path); + if (node is null || !node.Exists) + { + await ReplyAsync(550, "No such file or directory"); + return; + } + if (node.IsDirectory && node.Share is not null && node.IsRealDirectory && !node.Share.Listing) + { + await ReplyAsync(550, "Listing not allowed here"); + return; + } + if (kind == ListKind.Mlsd && !node.IsDirectory) + { + await ReplyAsync(501, "Not a directory"); + return; + } + + var entries = node.IsDirectory ? Present(node, Children(node)) : [EntryOf(node)]; + var now = DateTime.Now; + var sb = new StringBuilder(); + foreach (var e in entries) + { + switch (kind) + { + case ListKind.Unix: + sb.Append(FtpListing.UnixLine(e, now)); + break; + case ListKind.Names: + sb.Append(e.Name); + break; + default: + sb.Append(FtpListing.Facts(e, e.IsDirectory ? "dir" : "file", e.IsVirtual ? null : node.Share, !node.Writable)); + break; + } + sb.Append("\r\n"); + } + // MLSD es siempre UTF-8 (RFC 3659); LIST y NLST, en el juego de la sesion. + byte[] bytes = (kind == ListKind.Mlsd ? new UTF8Encoding(false) : NamesEncoding).GetBytes(sb.ToString()); + await TransferAsync("file list", (stream, ct) => stream.WriteAsync(bytes, ct).AsTask()); + } + + private async Task MlstAsync(string argument) + { + var node = Resolve(argument); + if (node is null || !node.Exists) + { + await ReplyAsync(550, "No such file or directory"); + return; + } + var entry = EntryOf(node) with { Name = FtpPath.Format(node.Segments) }; + string facts = FtpListing.Facts(entry, entry.IsDirectory ? "dir" : "file", node.Share, !(node.IsFile ? node.Share is not null : node.Writable)); + await ReplyMultiAsync(250, ["Listing " + FtpPath.Format(node.Segments), facts], "End"); + } + + // ------------------------------------------------------------------ ficheros + + private async Task RetrAsync(string argument) + { + long offset = _restart; + _restart = 0; + var node = Resolve(argument); + if (node is null || !node.IsFile) + { + await ReplyAsync(550, "No such file"); + return; + } + string path = node.Physical!; + bool ascii = _ascii; + bool macBin = node.MacBin; + bool ok = await TransferAsync(FtpPath.Format(node.Segments), async (stream, ct) => + { + await using var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 1, useAsync: true); + if (macBin) + { + // Siempre binario; REST se respeta descartando el principio de la codificacion. + await using var output = offset > 0 ? new SkipStream(stream, offset) : null; + await Mac.MacBinary.EncodeAsync(Mac.MacFiles.Describe(path), file, file.Length, (Stream?)output ?? stream, ct); + return; + } + if (offset > 0) + file.Position = Math.Min(offset, file.Length); + if (ascii) + await CopyAsciiAsync(file, stream, ct); + else + await file.CopyToAsync(stream, 65536, ct); + }); + if (ok) + _log.Info($"{RemoteAddress} FTP RETR {FtpPath.Format(node.Segments)}"); + } + + /// TYPE A: los saltos de linea solos (LF) se envian como CRLF. + private static async Task CopyAsciiAsync(Stream input, Stream output, CancellationToken ct) + { + var buffer = new byte[32768]; + var outBuffer = new byte[65536]; + byte previous = 0; + int n; + while ((n = await input.ReadAsync(buffer, ct)) > 0) + { + int o = 0; + for (int i = 0; i < n; i++) + { + byte b = buffer[i]; + if (b == '\n' && previous != '\r') + outBuffer[o++] = (byte)'\r'; + outBuffer[o++] = b; + previous = b; + } + await output.WriteAsync(outBuffer.AsMemory(0, o), ct); + } + } + + private async Task StorAsync(string argument, bool append) + { + long offset = _restart; + _restart = 0; + var node = Resolve(argument); + // Subir "x.bin" no escribe sobre "x": se guarda tal cual y luego se decodifica. + if (node is { MacBin: true }) + node = node with { Physical = node.Physical + ".bin", MacBin = false }; + var target = node is null ? null : ParentOf(node); + if (node is null || target is not { } t || node.IsDirectory) + { + await ReplyAsync(553, "Cannot create a file here"); + return; + } + var share = node.Share!; + bool exists = node.IsFile; + if (!share.Upload || (exists && !share.Overwrite) || ((append || offset > 0) && !share.Overwrite && exists)) + { + await ReplyAsync(550, exists ? "File exists and overwriting is not allowed" : "Uploads are not allowed here"); + return; + } + if (!FileOperations.IsCreatableName(t.Name)) + { + await ReplyAsync(553, "Invalid file name"); + return; + } + string directory = t.Parent.Physical!; + string path = Path.Combine(directory, t.Name); + bool inPlace = append || offset > 0; + FileOpResult result = FileOpResult.Ok; + bool ok = await TransferAsync(FtpPath.Format(node.Segments), async (stream, ct) => + { + if (inPlace) + { + // APPE y REST+STOR escriben sobre el fichero (reanudar una subida). + await using var file = new FileStream(path, FileMode.OpenOrCreate, FileAccess.Write, FileShare.None, 65536, useAsync: true); + file.Position = append ? file.Length : Math.Min(offset, file.Length); + await stream.CopyToAsync(file, 65536, ct); + file.SetLength(file.Position); + } + else + { + result = await FileOperations.SaveAsync(directory, t.Name, stream, share.Overwrite, ct); + // Se decodifica antes del 226, para que el cliente ya vea el resultado. + if (result == FileOpResult.Ok && share.Mac) + await UnpackMacAsync(share, path); + } + }); + if (ok && result == FileOpResult.Ok) + { + _log.Info($"{RemoteAddress} FTP {(append ? "APPE" : "STOR")} {FtpPath.Format(node.Segments)}"); + } + else if (ok) + _log.Warning($"{RemoteAddress} FTP STOR {FtpPath.Format(node.Segments)}: {result}"); + } + + /// Tras un STOR completo: un MacBinary o BinHex se separa en data fork + AppleDouble. + private async Task UnpackMacAsync(ShareConfig share, string path) + { + try + { + var (result, name) = await Mac.MacFiles.TryUnpackAsync(path, share.Overwrite, _profile == ClientKind.Mac, _shutdown); + if (result == Mac.MacFiles.Unpacked.Unpacked) + _log.Info($"{RemoteAddress} FTP mac {Path.GetFileName(path)} -> {name}"); + else if (result == Mac.MacFiles.Unpacked.ExistsKept) + _log.Warning($"{RemoteAddress} FTP mac {Path.GetFileName(path)}: name inside already exists, kept encoded"); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException or EndOfStreamException) + { + _log.Warning($"{RemoteAddress} FTP mac {Path.GetFileName(path)}: {e.Message}"); + } + } + + private async Task SizeAsync(string argument) + { + var node = Resolve(argument); + if (node is null || !node.IsFile) + await ReplyAsync(550, "No such file"); + else + await ReplyAsync(213, EntryOf(node).Size.ToString(CultureInfo.InvariantCulture)); + } + + /// Descarta los primeros bytes escritos (REST sobre una codificacion generada al vuelo). + private sealed class SkipStream(Stream inner, long skip) : Stream + { + private long _skip = skip; + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() => inner.Flush(); + public override Task FlushAsync(CancellationToken ct) => inner.FlushAsync(ct); + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => WriteAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken ct = default) + { + if (_skip >= buffer.Length) + { + _skip -= buffer.Length; + return; + } + var rest = buffer[(int)_skip..]; + _skip = 0; + await inner.WriteAsync(rest, ct); + } + // No cierra la conexion de datos: eso lo hace quien la abrio. + protected override void Dispose(bool disposing) { } + public override ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + private async Task MdtmAsync(string argument) + { + var node = Resolve(argument); + if (node is null || !node.IsFile) + await ReplyAsync(550, "No such file"); + else + await ReplyAsync(213, FtpListing.Timestamp(File.GetLastWriteTime(node.Physical!))); + } + + private async Task MkdAsync(string argument) + { + var node = Resolve(argument); + var target = node is null ? null : ParentOf(node); + if (node is null || target is not { } t || !node.Share!.Upload) + { + await ReplyAsync(550, "Cannot create a directory here"); + return; + } + var result = FileOperations.CreateFolder(t.Parent.Physical!, t.Name); + if (result == FileOpResult.Ok) + { + _log.Info($"{RemoteAddress} FTP MKD {FtpPath.Format(node.Segments)}"); + await ReplyAsync(257, FtpPath.Quote(FtpPath.Format(node.Segments)) + " created"); + } + else + { + await ReplyAsync(550, Strings.OpResult(result, t.Name)); + } + } + + private async Task RmdAsync(string argument) + { + var node = Resolve(argument); + if (node is null || !node.IsRealDirectory || ParentOf(node) is null || !node.Share!.Delete) + { + await ReplyAsync(550, "Cannot remove this directory"); + return; + } + var result = FileOperations.DeleteEmptyFolder(node.Physical!); + if (result == FileOpResult.Ok) + { + _log.Info($"{RemoteAddress} FTP RMD {FtpPath.Format(node.Segments)}"); + await ReplyAsync(250, "Directory removed"); + } + else + { + await ReplyAsync(550, result == FileOpResult.Conflict ? "Directory not empty" : "Cannot remove this directory"); + } + } + + private async Task DeleAsync(string argument) + { + var node = Resolve(argument); + if (node is null || !node.IsFile || ParentOf(node) is null || !node.Share!.Delete) + { + await ReplyAsync(550, "Cannot delete this file"); + return; + } + var result = FileOperations.Delete(node.Physical!); + if (result == FileOpResult.Ok) + _log.Info($"{RemoteAddress} FTP DELE {FtpPath.Format(node.Segments)}"); + await ReplyAsync(result == FileOpResult.Ok ? 250 : 550, result == FileOpResult.Ok ? "File deleted" : "Cannot delete this file"); + } + + private async Task RnfrAsync(string argument) + { + var node = Resolve(argument); + if (node is null || !node.Exists || node.Share is null || ParentOf(node) is null || !node.Share.Rename) + { + _renameFrom = null; + await ReplyAsync(550, "Cannot rename this"); + return; + } + _renameFrom = node.Segments; + await ReplyAsync(350, "Ready for RNTO"); + } + + private async Task RntoAsync(string argument) + { + var fromSegments = _renameFrom; + _renameFrom = null; + if (fromSegments is null) + { + await ReplyAsync(503, "Send RNFR first"); + return; + } + var from = Resolve(FtpPath.Format(fromSegments)); + var to = Resolve(argument); + var target = to is null ? null : ParentOf(to); + if (from is null || !from.Exists || to is null || target is null || from.Share != to.Share || !from.Share!.Rename) + { + await ReplyAsync(553, "Cannot rename to that name"); + return; + } + var result = FileOperations.Move(from.Physical!, Path.Combine(target.Value.Parent.Physical!, target.Value.Name)); + if (result == FileOpResult.Ok) + { + _log.Info($"{RemoteAddress} FTP RNTO {FtpPath.Format(from.Segments)} -> {FtpPath.Format(to.Segments)}"); + await ReplyAsync(250, "Rename successful"); + } + else + { + await ReplyAsync(553, Strings.OpResult(result, target.Value.Name)); + } + } +} diff --git a/src/Sunfish.Core/Host/SunfishHost.cs b/src/Sunfish.Core/Host/SunfishHost.cs new file mode 100644 index 0000000..50c822a --- /dev/null +++ b/src/Sunfish.Core/Host/SunfishHost.cs @@ -0,0 +1,313 @@ +using System.Net; +using System.Net.Sockets; +using Sunfish.Auth; +using Sunfish.Config; +using Sunfish.Ftp; +using Sunfish.Http; +using Sunfish.Log; +using Sunfish.Net; +using Sunfish.Web; + +namespace Sunfish.Host; + +/// +/// Punto de entrada para las aplicaciones (GUI, TUI, consola): arranca y para los servicios +/// (HTTP y FTP), aplica y guarda la configuracion, vigila el fichero por si otro programa lo +/// cambia y expone el estado. Las aplicaciones no contienen logica de servidor. +/// +public sealed class SunfishHost : IAsyncDisposable +{ + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly Authenticator _auth = new(); + private volatile ServerState _state; + private HttpServer? _http; + private FtpServer? _ftp; + private MdnsAnnouncer? _mdns; + private bool _running; + private FileSystemWatcher? _watcher; + private string? _lastWritten; + private CancellationTokenSource? _reloadDelay; + + public SunfishHost(SunfishConfig config, ServerLog? log = null) + { + _state = new ServerState(config); + Log = log ?? new ServerLog(); + } + + public ServerLog Log { get; } + + public SunfishConfig Config => _state.Config; + + /// + /// Ajustes que mandan sobre el fichero (p. ej. -p en la linea de comandos); se vuelven a + /// aplicar a cada configuracion recargada del disco. + /// + public Action? Overrides { get; set; } + + /// Cambio de estado (arranque, parada, configuracion nueva). Llega en cualquier hilo. + public event Action? Changed; + + /// El servidor esta en marcha (con los servicios que la configuracion active). + public bool Running => _running; + + public bool HttpRunning => _http is not null; + public bool FtpRunning => _ftp is not null; + + /// Donde escucha HTTP (puerto real si se pidio el 0). + public IReadOnlyList HttpEndpoints => _http?.Endpoints ?? []; + public IReadOnlyList FtpEndpoints => _ftp?.Endpoints ?? []; + + /// Puerto HTTP en uso (o el configurado si esta parado). + public int HttpPort => HttpEndpoints.Count > 0 ? HttpEndpoints[0].Port : Config.Server.HttpPort; + public int FtpPort => FtpEndpoints.Count > 0 ? FtpEndpoints[0].Port : Config.Server.FtpPort; + + /// + /// Arranca los servicios activados en la configuracion. Lanza + /// si un puerto esta ocupado (los que ya arrancaron siguen en marcha). + /// + public void Start() + { + _gate.Wait(); + try + { + _running = true; + try + { + StartServicesLocked(); + } + finally + { + UpdateAnnouncerLocked().GetAwaiter().GetResult(); + } + } + finally + { + _gate.Release(); + Changed?.Invoke(); + } + } + + public async Task StopAsync() + { + await _gate.WaitAsync(); + try + { + _running = false; + await StopHttpLocked(); + await StopFtpLocked(); + await UpdateAnnouncerLocked(); + } + finally + { + _gate.Release(); + } + Changed?.Invoke(); + } + + /// Anuncio DNS-SD acorde al estado: en marcha con Announce, o parado. + private async Task UpdateAnnouncerLocked() + { + bool wanted = _running && Config.Server.Announce && (_http is not null || _ftp is not null); + if (!wanted) + { + if (_mdns is { } old) + { + _mdns = null; + await old.DisposeAsync(); + } + return; + } + var services = new AnnouncedServices(Config.Server.Name, + _http is not null ? HttpPort : 0, + Config.Shares.Any(s => s.Enabled && s.WebDav), + _ftp is not null ? FtpPort : 0); + if (_mdns is null) + _mdns = MdnsAnnouncer.Start(services, Log); + else + _mdns.Update(services); + } + + private void StartServicesLocked() + { + SocketException? failure = null; + try + { + StartHttpLocked(); + } + catch (SocketException e) + { + failure = e; + Log.Error(Strings.CannotListen(Config.Server.HttpPort, e.Message)); + } + try + { + StartFtpLocked(); + } + catch (SocketException e) + { + failure ??= e; + Log.Error(Strings.CannotListen(Config.Server.FtpPort, e.Message)); + } + if (failure is not null) + throw failure; + } + + private void StartHttpLocked() + { + if (Config.Server.Http && _http is null) + { + var web = new WebApp(() => _state, Log, _auth, this); + var server = new HttpServer(Config.Server.Bind, Config.Server.HttpPort, web.HandleAsync, Log); + server.Start(); + _http = server; + } + } + + private void StartFtpLocked() + { + if (Config.Server.Ftp && _ftp is null) + { + var server = new FtpServer(() => _state, Log, _auth); + server.Start(); + _ftp = server; + } + } + + private async Task StopHttpLocked() + { + if (_http is { } http) + { + _http = null; + await http.DisposeAsync(); + } + } + + private async Task StopFtpLocked() + { + if (_ftp is { } ftp) + { + _ftp = null; + await ftp.DisposeAsync(); + } + } + + /// + /// Aplica una configuracion nueva sin cortar conexiones. Si el servidor esta en marcha y + /// cambian puertos, direcciones o los interruptores HTTP/FTP, se reinicia el servicio afectado. + /// Devuelve false (y lo anota en el registro) si no se pudo volver a arrancar. + /// + public async Task ApplyAsync(SunfishConfig config) + { + bool ok = true; + await _gate.WaitAsync(); + try + { + var old = _state.Config.Server; + _state = new ServerState(config); + var now = config.Server; + if (_running) + { + bool bind = !old.Bind.SequenceEqual(now.Bind); + if (bind || old.Http != now.Http || old.HttpPort != now.HttpPort) + { + await StopHttpLocked(); + ok &= TryStart(StartHttpLocked, now.HttpPort); + } + if (bind || old.Ftp != now.Ftp || old.FtpPort != now.FtpPort) + { + await StopFtpLocked(); + ok &= TryStart(StartFtpLocked, now.FtpPort); + } + await UpdateAnnouncerLocked(); + } + } + finally + { + _gate.Release(); + } + Changed?.Invoke(); + return ok; + } + + private bool TryStart(Action start, int port) + { + try + { + start(); + return true; + } + catch (SocketException e) + { + Log.Error(Strings.CannotListen(port, e.Message)); + return false; + } + } + + /// Guarda la configuracion en su fichero y la aplica (el vigilante no la recarga dos veces). + public async Task SaveAndApplyAsync(SunfishConfig config) + { + if (config.FilePath is not null) + { + config.Save(); + _lastWritten = File.ReadAllText(config.FilePath); + } + return await ApplyAsync(config); + } + + /// + /// Vigila el fichero de configuracion: si otro programa (un editor, sunfish-cli user...) lo + /// cambia, se recarga. Si el fichero nuevo tiene errores se aplica igualmente lo valido y se + /// anotan los errores, como al arrancar. + /// + public void WatchConfigFile() + { + string? path = Config.FilePath; + if (path is null || _watcher is not null) + return; + string full = Path.GetFullPath(path); + _lastWritten = File.Exists(full) ? File.ReadAllText(full) : null; + _watcher = new FileSystemWatcher(Path.GetDirectoryName(full)!, Path.GetFileName(full)) + { + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.Size, + }; + _watcher.Changed += (_, _) => ScheduleReload(full); + _watcher.Created += (_, _) => ScheduleReload(full); + _watcher.Renamed += (_, _) => ScheduleReload(full); + _watcher.EnableRaisingEvents = true; + } + + /// Los editores escriben en varias tandas: se espera un momento antes de leer. + private void ScheduleReload(string path) + { + var cts = new CancellationTokenSource(); + Interlocked.Exchange(ref _reloadDelay, cts)?.Cancel(); + _ = Task.Delay(400, cts.Token).ContinueWith(async t => + { + if (t.IsCanceled) + return; + try + { + string text = File.ReadAllText(path); + if (text == _lastWritten) + return; + _lastWritten = text; + var config = SunfishConfig.Parse(text); + config.FilePath = path; + Overrides?.Invoke(config); + foreach (var error in config.Errors) + Log.Warning(Strings.ConfigReloadError(error)); + Log.Info(Strings.ConfigReloaded); + await ApplyAsync(config); + } + catch (IOException) { ScheduleReload(path); } // aun bloqueado por quien escribe + catch (UnauthorizedAccessException) { } + }, TaskScheduler.Default); + } + + public async ValueTask DisposeAsync() + { + _watcher?.Dispose(); + _watcher = null; + await StopAsync(); + } +} diff --git a/src/Sunfish.Core/Http/BodyStreams.cs b/src/Sunfish.Core/Http/BodyStreams.cs new file mode 100644 index 0000000..eb6a2e1 --- /dev/null +++ b/src/Sunfish.Core/Http/BodyStreams.cs @@ -0,0 +1,129 @@ +using System.Globalization; + +namespace Sunfish.Http; + +/// Base de los cuerpos de peticion: solo lectura asincrona. +internal abstract class RequestBodyStream : Stream +{ + private Func? _onFirstRead; + + protected RequestBodyStream(HttpRequestReader reader, Func? onFirstRead) + { + Reader = reader; + _onFirstRead = onFirstRead; + } + + protected HttpRequestReader Reader { get; } + + /// Se ha leido el cuerpo entero. + public abstract bool Completed { get; } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) + { + if (_onFirstRead is not null) + { + // Expect: 100-continue: el cliente espera permiso antes de enviar el cuerpo. + var send = _onFirstRead; + _onFirstRead = null; + await send(); + } + return await ReadBodyAsync(buffer, ct); + } + + protected abstract ValueTask ReadBodyAsync(Memory buffer, CancellationToken ct); + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) => + ReadAsync(buffer.AsMemory(offset, count), ct).AsTask(); + + public override int Read(byte[] buffer, int offset, int count) => + ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); + + /// Descarta lo que quede (hasta un limite) para poder leer la siguiente peticion. + public async Task DrainAsync(long limit, CancellationToken ct) + { + var buffer = new byte[16384]; + long total = 0; + while (!Completed) + { + int n = await ReadBodyAsync(buffer, ct); + if (n == 0) + return Completed; + total += n; + if (total > limit) + return false; + } + return true; + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); +} + +/// Cuerpo con Content-Length. +internal sealed class ContentLengthReadStream(HttpRequestReader reader, long length, Func? onFirstRead) + : RequestBodyStream(reader, onFirstRead) +{ + private long _remaining = length; + + public override bool Completed => _remaining == 0; + + protected override async ValueTask ReadBodyAsync(Memory buffer, CancellationToken ct) + { + if (_remaining == 0) + return 0; + if (buffer.Length > _remaining) + buffer = buffer[..(int)_remaining]; + int n = await Reader.ReadRawAsync(buffer, ct); + if (n == 0) + throw new IOException("Connection closed in body"); + _remaining -= n; + return n; + } +} + +/// Cuerpo con Transfer-Encoding: chunked. +internal sealed class ChunkedReadStream(HttpRequestReader reader, Func? onFirstRead) + : RequestBodyStream(reader, onFirstRead) +{ + private long _chunkRemaining; + private bool _done; + + public override bool Completed => _done; + + protected override async ValueTask ReadBodyAsync(Memory buffer, CancellationToken ct) + { + if (_done) + return 0; + if (_chunkRemaining == 0) + { + string line = await Reader.ReadLineAsync(ct, countAsHeader: false) ?? throw new IOException("Connection closed in chunk"); + int semi = line.IndexOf(';'); + string size = (semi < 0 ? line : line[..semi]).Trim(); + if (!long.TryParse(size, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out _chunkRemaining) || _chunkRemaining < 0) + throw new HttpParseException(400, "Bad chunk size"); + if (_chunkRemaining == 0) + { + // Trailers hasta la linea vacia. + while (!string.IsNullOrEmpty(await Reader.ReadLineAsync(ct, countAsHeader: false))) { } + _done = true; + return 0; + } + } + if (buffer.Length > _chunkRemaining) + buffer = buffer[..(int)_chunkRemaining]; + int n = await Reader.ReadRawAsync(buffer, ct); + if (n == 0) + throw new IOException("Connection closed in chunk"); + _chunkRemaining -= n; + if (_chunkRemaining == 0) + await Reader.ReadLineAsync(ct, countAsHeader: false); // CRLF tras los datos del trozo + return n; + } +} diff --git a/src/Sunfish.Core/Http/HttpRequest.cs b/src/Sunfish.Core/Http/HttpRequest.cs new file mode 100644 index 0000000..404d754 --- /dev/null +++ b/src/Sunfish.Core/Http/HttpRequest.cs @@ -0,0 +1,119 @@ +using System.Net; +using Sunfish.Shares; + +namespace Sunfish.Http; + +/// Cabeceras HTTP: conservan el orden, nombres sin distinguir mayusculas. +public sealed class HttpHeaders +{ + private readonly List> _items = new(); + + public IEnumerable> All => _items; + + public string? this[string name] + { + get + { + foreach (var item in _items) + if (string.Equals(item.Key, name, StringComparison.OrdinalIgnoreCase)) + return item.Value; + return null; + } + set + { + _items.RemoveAll(i => string.Equals(i.Key, name, StringComparison.OrdinalIgnoreCase)); + if (value is not null) + _items.Add(new(name, value)); + } + } + + public void Add(string name, string value) => _items.Add(new(name, value)); + + /// Anade texto a la ultima cabecera (lineas de continuacion obsoletas). + internal bool AppendToLast(string text) + { + if (_items.Count == 0) + return false; + var last = _items[^1]; + _items[^1] = new(last.Key, last.Value + " " + text); + return true; + } + + /// Contiene el elemento (separado por comas), p. ej. Connection: keep-alive, Upgrade. + public bool HasToken(string name, string token) + { + string? value = this[name]; + if (value is null) + return false; + foreach (var part in value.Split(',')) + if (part.Trim().Equals(token, StringComparison.OrdinalIgnoreCase)) + return true; + return false; + } +} + +/// Peticion HTTP ya analizada. +public sealed class HttpRequest +{ + private Dictionary? _query; + + public string Method { get; init; } = "GET"; + + /// Ruta tal como llega (codificada con %XX), sin query. + public string Path { get; init; } = "/"; + + /// Query sin el '?', codificada. + public string QueryString { get; init; } = ""; + + public int VersionMajor { get; init; } = 1; + public int VersionMinor { get; init; } + + /// HTTP/0.9: solo "GET /ruta", la respuesta va sin linea de estado ni cabeceras. + public bool IsHttp09 => VersionMajor == 0; + + public bool IsHttp11OrLater => VersionMajor > 1 || (VersionMajor == 1 && VersionMinor >= 1); + + public HttpHeaders Headers { get; } = new(); + + public IPEndPoint? RemoteEndPoint { get; init; } + public IPEndPoint? LocalEndPoint { get; init; } + + /// Cuerpo de la peticion (vacio si no lleva). + public Stream Body { get; internal set; } = Stream.Null; + + public bool IsHead => Method == "HEAD"; + + /// El cliente quiere mantener la conexion (1.1 por defecto, 1.0 si lo pide). + public bool WantsKeepAlive => + !IsHttp09 && (IsHttp11OrLater + ? !Headers.HasToken("Connection", "close") + : Headers.HasToken("Connection", "keep-alive")); + + /// Parametros de la query decodificados. Un parametro sin '=' vale "". + public IReadOnlyDictionary Query => _query ??= ParseQuery(QueryString); + + public string? GetQuery(string name) => Query.TryGetValue(name, out var value) ? value : null; + + public bool HasQuery(string name) => Query.ContainsKey(name); + + internal static Dictionary ParseQuery(string query) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (var pair in query.Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + int eq = pair.IndexOf('='); + string name = Decode(eq < 0 ? pair : pair[..eq]); + string value = eq < 0 ? "" : Decode(pair[(eq + 1)..]); + result.TryAdd(name, value); + } + return result; + } + + private static string Decode(string text) => PathRules.PercentDecode(text.Replace('+', ' ')); +} + +/// Peticion mal formada: se responde con el estado indicado y se cierra. +public sealed class HttpParseException(int status, string message) : Exception(message) +{ + public int Status { get; } = status; +} diff --git a/src/Sunfish.Core/Http/HttpRequestReader.cs b/src/Sunfish.Core/Http/HttpRequestReader.cs new file mode 100644 index 0000000..adbd731 --- /dev/null +++ b/src/Sunfish.Core/Http/HttpRequestReader.cs @@ -0,0 +1,220 @@ +using System.Globalization; +using System.Net; +using System.Text; + +namespace Sunfish.Http; + +/// +/// Lee peticiones de una conexion. Es tolerante a proposito con clientes antiguos: acepta lineas +/// acabadas solo en LF, HTTP/0.9, HTTP/1.0 sin Host, lineas en blanco antes de la peticion, +/// cabeceras partidas en varias lineas y lineas de cabecera que no se entienden (se ignoran). +/// +internal sealed class HttpRequestReader +{ + public const int MaxLineLength = 8192; + public const int MaxHeaderBytes = 16384; + public const int MaxHeaderCount = 100; + + private readonly Stream _stream; + private readonly byte[] _buffer = new byte[16384]; + private int _pos; + private int _len; + private int _headerBytes; + + public HttpRequestReader(Stream stream) => _stream = stream; + + public IPEndPoint? Remote { get; init; } + public IPEndPoint? Local { get; init; } + + /// + /// Lee la siguiente peticion. Devuelve null si la conexion se cierra limpiamente antes de + /// empezar otra. Lanza si esta mal formada. + /// + public async Task ReadRequestAsync(Func sendContinue, CancellationToken ct) + { + _headerBytes = 0; + string? line; + // Lineas en blanco sueltas antes de la peticion (restos de un POST anterior en clientes viejos). + do + { + line = await ReadLineAsync(ct); + if (line is null) + return null; + } while (line.Length == 0); + + var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2 || parts.Length > 3) + throw new HttpParseException(400, "Bad request line"); + + string method = parts[0].ToUpperInvariant(); + int major = 0, minor = 9; + if (parts.Length == 3) + { + if (!TryParseVersion(parts[2], out major, out minor)) + throw new HttpParseException(400, "Bad HTTP version"); + if (major > 1) + throw new HttpParseException(505, "HTTP version not supported"); + } + else if (method != "GET") + { + throw new HttpParseException(400, "HTTP/0.9 only allows GET"); + } + + var (path, query) = SplitTarget(parts[1]); + var request = new HttpRequest + { + Method = method, + Path = path, + QueryString = query, + VersionMajor = major, + VersionMinor = minor, + RemoteEndPoint = Remote, + LocalEndPoint = Local, + }; + if (major == 0) + return request; // HTTP/0.9: no hay cabeceras + + while (true) + { + line = await ReadLineAsync(ct) ?? throw new HttpParseException(400, "Connection closed in headers"); + if (line.Length == 0) + break; + if (line[0] == ' ' || line[0] == '\t') + { + request.Headers.AppendToLast(line.Trim()); + continue; + } + int colon = line.IndexOf(':'); + if (colon <= 0) + continue; + if (request.Headers.All.Count() >= MaxHeaderCount) + throw new HttpParseException(431, "Too many headers"); + request.Headers.Add(line[..colon].Trim(), line[(colon + 1)..].Trim()); + } + + request.Body = CreateBody(request, sendContinue); + return request; + } + + private Stream CreateBody(HttpRequest request, Func sendContinue) + { + bool expectContinue = request.IsHttp11OrLater && + string.Equals(request.Headers["Expect"], "100-continue", StringComparison.OrdinalIgnoreCase); + Func? onFirstRead = expectContinue ? sendContinue : null; + + if (request.Headers.HasToken("Transfer-Encoding", "chunked")) + return new ChunkedReadStream(this, onFirstRead); + string? length = request.Headers["Content-Length"]; + if (length is not null) + { + if (!long.TryParse(length, NumberStyles.None, CultureInfo.InvariantCulture, out long value)) + throw new HttpParseException(400, "Bad Content-Length"); + if (value > 0) + return new ContentLengthReadStream(this, value, onFirstRead); + } + return Stream.Null; + } + + /// + /// "/a%20b?x=1" o "http://host/a?x" -> ("/a%20b", "x=1"). Los bytes no ASCII que algunos + /// clientes envian sin codificar se pasan a %XX para decodificarlos despues como el resto. + /// + internal static (string Path, string Query) SplitTarget(string target) + { + int hash = target.IndexOf('#'); + if (hash >= 0) + target = target[..hash]; + if (target.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + target.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + int start = target.IndexOf("//", StringComparison.Ordinal) + 2; + int slash = target.IndexOf('/', start); + target = slash < 0 ? "/" : target[slash..]; + } + if (target.Length == 0 || target[0] != '/') + target = "/" + target; + + var sb = new StringBuilder(target.Length); + foreach (char c in target) + { + if (c >= 0x80 || c == ' ') + sb.Append('%').Append(((int)c).ToString("X2", CultureInfo.InvariantCulture)); + else + sb.Append(c); + } + string encoded = sb.ToString(); + int q = encoded.IndexOf('?'); + return q < 0 ? (encoded, "") : (encoded[..q], encoded[(q + 1)..]); + } + + private static bool TryParseVersion(string text, out int major, out int minor) + { + major = minor = 0; + if (!text.StartsWith("HTTP/", StringComparison.OrdinalIgnoreCase)) + return false; + var nums = text[5..].Split('.'); + return nums.Length == 2 && + int.TryParse(nums[0], NumberStyles.None, CultureInfo.InvariantCulture, out major) && + int.TryParse(nums[1], NumberStyles.None, CultureInfo.InvariantCulture, out minor); + } + + /// + /// Lee una linea hasta LF (quitando el CR si lo hay), como Latin-1: cada byte es un caracter. + /// Devuelve null si la conexion se cierra sin datos. + /// + internal async ValueTask ReadLineAsync(CancellationToken ct, bool countAsHeader = true) + { + if (!countAsHeader) + _headerBytes = 0; // lineas de trozos del cuerpo: solo cuenta el limite de cada linea + var sb = new StringBuilder(); + bool any = false; + while (true) + { + if (_pos >= _len) + { + if (!await FillAsync(ct)) + { + if (!any) + return null; + throw new HttpParseException(400, "Connection closed in line"); + } + } + any = true; + int lf = Array.IndexOf(_buffer, (byte)'\n', _pos, _len - _pos); + int end = lf < 0 ? _len : lf; + sb.Append(Encoding.Latin1.GetString(_buffer, _pos, end - _pos)); + _headerBytes += end - _pos + (lf < 0 ? 0 : 1); + _pos = lf < 0 ? _len : lf + 1; + if (sb.Length > MaxLineLength) + throw new HttpParseException(414, "Line too long"); + if (_headerBytes > MaxHeaderBytes) + throw new HttpParseException(431, "Headers too large"); + if (lf >= 0) + { + if (sb.Length > 0 && sb[^1] == '\r') + sb.Length--; + return sb.ToString(); + } + } + } + + /// Lee datos del cuerpo: primero lo que quede en el bufer, despues del socket. + internal async ValueTask ReadRawAsync(Memory destination, CancellationToken ct) + { + if (_pos < _len) + { + int n = Math.Min(destination.Length, _len - _pos); + _buffer.AsMemory(_pos, n).CopyTo(destination); + _pos += n; + return n; + } + return await _stream.ReadAsync(destination, ct); + } + + private async ValueTask FillAsync(CancellationToken ct) + { + _pos = 0; + _len = await _stream.ReadAsync(_buffer, ct); + return _len > 0; + } +} diff --git a/src/Sunfish.Core/Http/HttpResponse.cs b/src/Sunfish.Core/Http/HttpResponse.cs new file mode 100644 index 0000000..0dae319 --- /dev/null +++ b/src/Sunfish.Core/Http/HttpResponse.cs @@ -0,0 +1,210 @@ +using System.Globalization; +using System.Text; + +namespace Sunfish.Http; + +/// +/// Respuesta HTTP. Las cabeceras se envian al empezar el cuerpo. Con longitud conocida se usa +/// Content-Length (los clientes antiguos no conocen chunked y asi muestran el progreso); sin ella, +/// chunked para HTTP/1.1 y cierre de conexion para 1.0. HTTP/0.9 recibe solo el cuerpo. +/// +public sealed class HttpResponse +{ + private readonly Stream _stream; + private readonly HttpRequest _request; + private Stream? _body; + + internal HttpResponse(Stream stream, HttpRequest request) + { + _stream = stream; + _request = request; + KeepAlive = request.WantsKeepAlive; + } + + public int StatusCode { get; set; } = 200; + + public HttpHeaders Headers { get; } = new(); + + public bool HeadersSent => _body is not null; + + /// Si la conexion seguira abierta despues de esta respuesta. + public bool KeepAlive { get; private set; } + + /// Bytes de cuerpo enviados (para el registro). + public long BytesSent => _body is CountingStream c ? c.Count : 0; + + /// Envia la cabecera y devuelve el flujo para escribir el cuerpo. + public async Task StartAsync(long? contentLength, CancellationToken ct = default) + { + if (_body is not null) + throw new InvalidOperationException("Response already started"); + + bool bodyAllowed = StatusCode is not (204 or 304) && StatusCode >= 200; + bool chunked = false; + if (bodyAllowed && contentLength is long length) + { + Headers["Content-Length"] = length.ToString(CultureInfo.InvariantCulture); + } + else if (bodyAllowed) + { + if (_request.IsHttp11OrLater) + { + Headers["Transfer-Encoding"] = "chunked"; + chunked = true; + } + else + { + KeepAlive = false; // 1.0 sin longitud: el final del cuerpo es el cierre + } + } + + if (!_request.IsHttp09) + { + if (KeepAlive) + { + if (!_request.IsHttp11OrLater) + Headers["Connection"] = "keep-alive"; + } + else + { + Headers["Connection"] = "close"; + } + Headers["Date"] ??= FormatDate(DateTime.UtcNow); + Headers["Server"] ??= "Sunfish/3.0"; + await _stream.WriteAsync(BuildHead(), ct); + } + + Stream body = _stream; + if (_request.IsHead || !bodyAllowed) + body = Stream.Null; + else if (chunked) + body = new ChunkedWriteStream(_stream); + _body = new CountingStream(body); + return _body; + } + + /// Respuesta completa con el cuerpo en memoria. + public async Task SendAsync(byte[] content, string? contentType, CancellationToken ct = default) + { + if (contentType is not null) + Headers["Content-Type"] = contentType; + var body = await StartAsync(content.Length, ct); + await body.WriteAsync(content, ct); + } + + /// Termina la respuesta: si no se envio nada, una vacia; si era chunked, el trozo final. + internal async Task CompleteAsync(CancellationToken ct) + { + if (_body is null) + await StartAsync(0, ct); + if (_body is CountingStream { Inner: ChunkedWriteStream chunked }) + await chunked.FinishAsync(ct); + await _stream.FlushAsync(ct); + } + + private byte[] BuildHead() + { + var sb = new StringBuilder(); + string version = _request.IsHttp11OrLater ? "HTTP/1.1" : "HTTP/1.0"; + sb.Append(version).Append(' ').Append(StatusCode.ToString(CultureInfo.InvariantCulture)) + .Append(' ').Append(ReasonPhrase(StatusCode)).Append("\r\n"); + foreach (var (name, value) in Headers.All) + sb.Append(name).Append(": ").Append(value).Append("\r\n"); + sb.Append("\r\n"); + return Encoding.Latin1.GetBytes(sb.ToString()); + } + + public static string FormatDate(DateTime utc) => + utc.ToString("r", CultureInfo.InvariantCulture); + + public static string ReasonPhrase(int status) => status switch + { + 100 => "Continue", + 200 => "OK", + 201 => "Created", + 204 => "No Content", + 206 => "Partial Content", + 207 => "Multi-Status", + 301 => "Moved Permanently", + 302 => "Found", + 304 => "Not Modified", + 400 => "Bad Request", + 401 => "Unauthorized", + 403 => "Forbidden", + 404 => "Not Found", + 405 => "Method Not Allowed", + 409 => "Conflict", + 412 => "Precondition Failed", + 413 => "Payload Too Large", + 414 => "URI Too Long", + 416 => "Range Not Satisfiable", + 431 => "Request Header Fields Too Large", + 500 => "Internal Server Error", + 501 => "Not Implemented", + 503 => "Service Unavailable", + 505 => "HTTP Version Not Supported", + _ => "Unknown", + }; +} + +/// Cuenta los bytes escritos. +internal sealed class CountingStream(Stream inner) : Stream +{ + public Stream Inner { get; } = inner; + public long Count { get; private set; } + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken ct = default) + { + await Inner.WriteAsync(buffer, ct); + Count += buffer.Length; + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken ct) => + WriteAsync(buffer.AsMemory(offset, count), ct).AsTask(); + + public override void Write(byte[] buffer, int offset, int count) => + WriteAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); + + public override Task FlushAsync(CancellationToken ct) => Inner.FlushAsync(ct); + public override void Flush() => Inner.Flush(); + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); +} + +/// Escritura con Transfer-Encoding: chunked. +internal sealed class ChunkedWriteStream(Stream inner) : Stream +{ + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken ct = default) + { + if (buffer.Length == 0) + return; // un trozo vacio marcaria el final + await inner.WriteAsync(Encoding.ASCII.GetBytes(buffer.Length.ToString("X", CultureInfo.InvariantCulture) + "\r\n"), ct); + await inner.WriteAsync(buffer, ct); + await inner.WriteAsync("\r\n"u8.ToArray(), ct); + } + + public Task FinishAsync(CancellationToken ct) => inner.WriteAsync("0\r\n\r\n"u8.ToArray(), ct).AsTask(); + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken ct) => + WriteAsync(buffer.AsMemory(offset, count), ct).AsTask(); + + public override void Write(byte[] buffer, int offset, int count) => + WriteAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); + + public override Task FlushAsync(CancellationToken ct) => inner.FlushAsync(ct); + public override void Flush() => inner.Flush(); + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); +} diff --git a/src/Sunfish.Core/Http/HttpServer.cs b/src/Sunfish.Core/Http/HttpServer.cs new file mode 100644 index 0000000..5453f38 --- /dev/null +++ b/src/Sunfish.Core/Http/HttpServer.cs @@ -0,0 +1,246 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; +using System.Text; +using Sunfish.Log; + +namespace Sunfish.Http; + +/// Peticion y respuesta en curso. +public sealed class HttpContext +{ + internal HttpContext(HttpRequest request, HttpResponse response, CancellationToken aborted) + { + Request = request; + Response = response; + Aborted = aborted; + } + + public HttpRequest Request { get; } + public HttpResponse Response { get; } + + /// Se cancela al parar el servidor. + public CancellationToken Aborted { get; } +} + +/// +/// Servidor HTTP propio sobre . No usa http.sys (que en Windows exige +/// administrador para escuchar fuera de localhost) ni Kestrel (estricto con clientes antiguos). +/// +public sealed class HttpServer : IAsyncDisposable +{ + public const int MaxConnectionsPerAddress = 32; + private const long MaxDrainBytes = 1 << 20; + + private readonly IReadOnlyList _bind; + private readonly int _port; + private readonly Func _handler; + private readonly ServerLog _log; + private readonly List _listeners = new(); + private readonly ConcurrentDictionary _connections = new(); + private readonly ConcurrentDictionary _perAddress = new(); + private readonly CancellationTokenSource _shutdown = new(); + private readonly List _acceptLoops = new(); + + public HttpServer(IReadOnlyList bind, int port, Func handler, ServerLog log) + { + _bind = bind; + _port = port; + _handler = handler; + _log = log; + } + + /// Direcciones en las que escucha de verdad (con el puerto real si se pidio el 0). + public IReadOnlyList Endpoints => + _listeners.Select(l => (IPEndPoint)l.LocalEndPoint!).ToList(); + + /// Abre los sockets. Lanza si el puerto esta ocupado. + public void Start() + { + foreach (var (address, dualMode) in PlanSockets(_bind)) + { + var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + try + { + if (address.AddressFamily == AddressFamily.InterNetworkV6) + socket.DualMode = dualMode; + if (OperatingSystem.IsWindows()) + socket.ExclusiveAddressUse = true; + else + socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + socket.Bind(new IPEndPoint(address, _port)); + socket.Listen(128); + } + catch + { + socket.Dispose(); + foreach (var opened in _listeners) + opened.Dispose(); + _listeners.Clear(); + throw; + } + _listeners.Add(socket); + } + foreach (var listener in _listeners) + { + _log.Info(Strings.HttpListening(listener.LocalEndPoint!.ToString()!)); + _acceptLoops.Add(AcceptLoopAsync(listener)); + } + } + + /// + /// 0.0.0.0 y :: a la vez se resuelven con un unico socket IPv6 en modo dual (en Linux + /// abrir los dos daria "direccion en uso"). + /// + internal static List<(IPAddress Address, bool DualMode)> PlanSockets(IReadOnlyList bind) + { + var plan = new List<(IPAddress, bool)>(); + bool any4 = bind.Contains(IPAddress.Any); + bool any6 = bind.Contains(IPAddress.IPv6Any) && Socket.OSSupportsIPv6; + if (any4 && any6) + plan.Add((IPAddress.IPv6Any, true)); + else if (any4) + plan.Add((IPAddress.Any, false)); + else if (any6) + plan.Add((IPAddress.IPv6Any, false)); + foreach (var address in bind) + { + if (address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any)) + continue; + if (address.AddressFamily == AddressFamily.InterNetworkV6 && !Socket.OSSupportsIPv6) + continue; + plan.Add((address, false)); + } + return plan; + } + + private async Task AcceptLoopAsync(Socket listener) + { + while (!_shutdown.IsCancellationRequested) + { + Socket client; + try + { + client = await listener.AcceptAsync(_shutdown.Token); + } + catch (OperationCanceledException) { break; } + catch (ObjectDisposedException) { break; } + catch (SocketException) { continue; } + + var remote = (IPEndPoint)client.RemoteEndPoint!; + var address = Normalize(remote.Address); + int count = _perAddress.AddOrUpdate(address, 1, (_, c) => c + 1); + if (count > MaxConnectionsPerAddress) + { + Release(address); + client.Dispose(); + continue; + } + var task = HandleConnectionAsync(client, remote); + _connections.TryAdd(task, 0); + _ = task.ContinueWith(t => + { + _connections.TryRemove(t, out _); + Release(address); + }, TaskScheduler.Default); + } + } + + private void Release(IPAddress address) => + _perAddress.AddOrUpdate(address, 0, (_, c) => Math.Max(0, c - 1)); + + private static IPAddress Normalize(IPAddress address) => + address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address; + + private async Task HandleConnectionAsync(Socket client, IPEndPoint remote) + { + client.NoDelay = true; + var displayRemote = new IPEndPoint(Normalize(remote.Address), remote.Port); + var local = (IPEndPoint)client.LocalEndPoint!; + var localNormalized = new IPEndPoint(Normalize(local.Address), local.Port); + await using var stream = new TimeoutStream(new NetworkStream(client, ownsSocket: true), _shutdown.Token); + var reader = new HttpRequestReader(stream) { Remote = displayRemote, Local = localNormalized }; + try + { + while (!_shutdown.IsCancellationRequested) + { + HttpRequest? request; + try + { + request = await reader.ReadRequestAsync( + () => stream.WriteAsync("HTTP/1.1 100 Continue\r\n\r\n"u8.ToArray()).AsTask(), + _shutdown.Token); + } + catch (HttpParseException e) + { + await SendBareErrorAsync(stream, e.Status); + _log.Detail($"{displayRemote}: {e.Status} {e.Message}"); + return; + } + if (request is null) + return; + + var response = new HttpResponse(stream, request); + var context = new HttpContext(request, response, _shutdown.Token); + try + { + await _handler(context); + } + catch (Exception e) when (e is not OperationCanceledException && !response.HeadersSent) + { + _log.Error($"{displayRemote} {request.Method} {request.Path}: {e.Message}"); + response.StatusCode = 500; + await response.SendAsync(Encoding.ASCII.GetBytes("500 Internal Server Error"), "text/plain"); + } + await response.CompleteAsync(_shutdown.Token); + + if (!response.KeepAlive) + return; + if (request.Body is RequestBodyStream body && !await body.DrainAsync(MaxDrainBytes, _shutdown.Token)) + return; + } + } + catch (Exception e) when (e is IOException or SocketException or OperationCanceledException or ObjectDisposedException) + { + // Cliente que cierra, plazo agotado o parada del servidor: no es un error del servidor. + _log.Detail(Strings.ConnectionError(displayRemote.ToString(), e.Message)); + } + catch (Exception e) + { + _log.Error(Strings.ConnectionError(displayRemote.ToString(), e.Message)); + } + } + + private static async Task SendBareErrorAsync(Stream stream, int status) + { + try + { + string body = $"{status} {HttpResponse.ReasonPhrase(status)}"; + string head = $"HTTP/1.0 {status} {HttpResponse.ReasonPhrase(status)}\r\nContent-Type: text/plain\r\n" + + $"Content-Length: {body.Length}\r\nConnection: close\r\n\r\n"; + await stream.WriteAsync(Encoding.ASCII.GetBytes(head + body)); + } + catch (Exception e) when (e is IOException or OperationCanceledException or ObjectDisposedException) { } + } + + public async Task StopAsync() + { + if (_shutdown.IsCancellationRequested) + return; + _shutdown.Cancel(); + foreach (var listener in _listeners) + listener.Dispose(); + try + { + await Task.WhenAll(_acceptLoops.Concat(_connections.Keys)).WaitAsync(TimeSpan.FromSeconds(5)); + } + catch (Exception e) when (e is TimeoutException or OperationCanceledException) { } + _log.Info(Strings.HttpStopped); + } + + public async ValueTask DisposeAsync() + { + await StopAsync(); + _shutdown.Dispose(); + } +} diff --git a/src/Sunfish.Core/Http/TimeoutStream.cs b/src/Sunfish.Core/Http/TimeoutStream.cs new file mode 100644 index 0000000..a24089f --- /dev/null +++ b/src/Sunfish.Core/Http/TimeoutStream.cs @@ -0,0 +1,55 @@ +namespace Sunfish.Http; + +/// +/// Envuelve el flujo de la conexion y cancela cada lectura o escritura que tarde demasiado +/// (conexiones abandonadas o lentas a proposito). El plazo es por operacion, no total: una +/// descarga larga por un modem lento no se corta mientras avance. +/// +internal sealed class TimeoutStream(Stream inner, CancellationToken shutdown) : Stream +{ + public TimeSpan ReadLimit { get; set; } = TimeSpan.FromSeconds(30); + public TimeSpan WriteLimit { get; set; } = TimeSpan.FromSeconds(120); + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct, shutdown); + cts.CancelAfter(ReadLimit); + return await inner.ReadAsync(buffer, cts.Token); + } + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken ct = default) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct, shutdown); + cts.CancelAfter(WriteLimit); + await inner.WriteAsync(buffer, cts.Token); + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) => + ReadAsync(buffer.AsMemory(offset, count), ct).AsTask(); + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken ct) => + WriteAsync(buffer.AsMemory(offset, count), ct).AsTask(); + + public override int Read(byte[] buffer, int offset, int count) => + ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); + + public override void Write(byte[] buffer, int offset, int count) => + WriteAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); + + public override Task FlushAsync(CancellationToken ct) => inner.FlushAsync(ct); + public override void Flush() => inner.Flush(); + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + inner.Dispose(); + base.Dispose(disposing); + } +} diff --git a/src/Sunfish.Core/Log/ServerLog.cs b/src/Sunfish.Core/Log/ServerLog.cs new file mode 100644 index 0000000..8e55a70 --- /dev/null +++ b/src/Sunfish.Core/Log/ServerLog.cs @@ -0,0 +1,48 @@ +namespace Sunfish.Log; + +public enum LogLevel { Detail, Info, Warning, Error } + +public sealed record LogEntry(DateTime Time, LogLevel Level, string Message); + +/// +/// Registro del servidor: guarda las ultimas entradas en memoria (para las interfaces) +/// y avisa de cada entrada nueva con . +/// +public sealed class ServerLog +{ + private readonly object _lock = new(); + private readonly Queue _recent = new(); + private readonly int _capacity; + + public ServerLog(int capacity = 1000) => _capacity = capacity; + + /// Se dispara en el hilo que escribe; las interfaces deben pasar a su propio hilo. + public event Action? Written; + + public LogLevel MinimumLevel { get; set; } = LogLevel.Info; + + public void Detail(string message) => Write(LogLevel.Detail, message); + public void Info(string message) => Write(LogLevel.Info, message); + public void Warning(string message) => Write(LogLevel.Warning, message); + public void Error(string message) => Write(LogLevel.Error, message); + + public void Write(LogLevel level, string message) + { + if (level < MinimumLevel) + return; + var entry = new LogEntry(DateTime.Now, level, message); + lock (_lock) + { + _recent.Enqueue(entry); + while (_recent.Count > _capacity) + _recent.Dequeue(); + } + Written?.Invoke(entry); + } + + public LogEntry[] Recent() + { + lock (_lock) + return _recent.ToArray(); + } +} diff --git a/src/Sunfish.Core/Mac/AppleDouble.cs b/src/Sunfish.Core/Mac/AppleDouble.cs new file mode 100644 index 0000000..6da038b --- /dev/null +++ b/src/Sunfish.Core/Mac/AppleDouble.cs @@ -0,0 +1,116 @@ +using System.Buffers.Binary; + +namespace Sunfish.Mac; + +/// +/// AppleDouble version 2: el fichero "._nombre" que acompana a "nombre" y guarda lo que no cabe en +/// un sistema de ficheros sin forks. Es el mismo formato que usa macOS en FAT, SMB, etc. +/// Se escriben las entradas 9 (Finder Info, 32 bytes), 8 (fechas) y 2 (resource fork). +/// +public static class AppleDouble +{ + public const string Prefix = "._"; + private const uint Magic = 0x00051607; + private const uint Version = 0x00020000; + + public static string CompanionOf(string physical) => + Path.Combine(Path.GetDirectoryName(physical)!, Prefix + Path.GetFileName(physical)); + + public static byte[] Write(MacFile file) + { + var entries = new List<(uint Id, byte[] Data)> + { + (9, file.FinderInfo), + (8, Dates(file)), + }; + if (file.ResourceFork.Length > 0) + entries.Add((2, file.ResourceFork)); // el resource fork al final, como hace macOS + int headerLength = 26 + 12 * entries.Count; + var result = new byte[headerLength + entries.Sum(e => e.Data.Length)]; + var s = result.AsSpan(); + BinaryPrimitives.WriteUInt32BigEndian(s, Magic); + BinaryPrimitives.WriteUInt32BigEndian(s[4..], Version); + "Mac OS X "u8.CopyTo(s[8..]); // filler de 16 bytes que escribe macOS + BinaryPrimitives.WriteUInt16BigEndian(s[24..], (ushort)entries.Count); + int offset = headerLength; + for (int i = 0; i < entries.Count; i++) + { + var e = s[(26 + 12 * i)..]; + BinaryPrimitives.WriteUInt32BigEndian(e, entries[i].Id); + BinaryPrimitives.WriteUInt32BigEndian(e[4..], (uint)offset); + BinaryPrimitives.WriteUInt32BigEndian(e[8..], (uint)entries[i].Data.Length); + entries[i].Data.CopyTo(s[offset..]); + offset += entries[i].Data.Length; + } + return result; + } + + /// Lee un AppleDouble; null si no lo es. + public static MacFile? Read(ReadOnlySpan data, string name = "") + { + if (data.Length < 26 || BinaryPrimitives.ReadUInt32BigEndian(data) != Magic) + return null; + int count = BinaryPrimitives.ReadUInt16BigEndian(data[24..]); + if (26 + 12 * count > data.Length) + return null; + var file = new MacFile { Name = name }; + for (int i = 0; i < count; i++) + { + var e = data[(26 + 12 * i)..]; + uint id = BinaryPrimitives.ReadUInt32BigEndian(e); + int offset = (int)BinaryPrimitives.ReadUInt32BigEndian(e[4..]); + int length = (int)BinaryPrimitives.ReadUInt32BigEndian(e[8..]); + if (offset < 0 || length < 0 || offset + length > data.Length) + return null; + var payload = data.Slice(offset, length); + switch (id) + { + case 2: + file.ResourceFork = payload.ToArray(); + break; + case 9: + payload[..Math.Min(32, payload.Length)].CopyTo(file.FinderInfo); + break; + case 8 when length >= 8: + // Fechas de AppleDouble: segundos con signo desde el 1 de enero de 2000 (UTC). + file.Created = FromDoubleDate(BinaryPrimitives.ReadInt32BigEndian(payload)); + file.Modified = FromDoubleDate(BinaryPrimitives.ReadInt32BigEndian(payload[4..])); + break; + } + } + return file; + } + + public static MacFile? ReadCompanion(string physical) + { + string companion = CompanionOf(physical); + if (!File.Exists(companion)) + return null; + try + { + return Read(File.ReadAllBytes(companion), Path.GetFileName(physical)); + } + catch (IOException) + { + return null; + } + } + + private static readonly DateTime DoubleEpoch = new(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + private static DateTime? FromDoubleDate(int seconds) => + seconds == int.MinValue ? null : DoubleEpoch.AddSeconds(seconds).ToLocalTime(); + + private static byte[] Dates(MacFile file) + { + var data = new byte[16]; + BinaryPrimitives.WriteInt32BigEndian(data, ToDoubleDate(file.Created)); + BinaryPrimitives.WriteInt32BigEndian(data.AsSpan(4), ToDoubleDate(file.Modified)); + BinaryPrimitives.WriteInt32BigEndian(data.AsSpan(8), int.MinValue); // backup: nunca + BinaryPrimitives.WriteInt32BigEndian(data.AsSpan(12), ToDoubleDate(file.Modified)); // acceso + return data; + } + + private static int ToDoubleDate(DateTime? time) => + time is { } t ? (int)Math.Clamp((t.ToUniversalTime() - DoubleEpoch).TotalSeconds, int.MinValue + 1, int.MaxValue) : int.MinValue; +} diff --git a/src/Sunfish.Core/Mac/BinHex.cs b/src/Sunfish.Core/Mac/BinHex.cs new file mode 100644 index 0000000..8fd71f0 --- /dev/null +++ b/src/Sunfish.Core/Mac/BinHex.cs @@ -0,0 +1,210 @@ +using System.Buffers.Binary; +using System.Text; + +namespace Sunfish.Mac; + +/// +/// BinHex 4.0 (.hqx): texto de 7 bits con un alfabeto de 64 caracteres, compresion RLE (0x90) y +/// tres CRC (cabecera, data fork, resource fork). Pensado para ficheros moderados: se trabaja en +/// memoria (el limite lo pone quien llama). +/// +public static class BinHex +{ + public const string Banner = "(This file must be converted with BinHex 4.0)"; + private const string Alphabet = "!\"#$%&'()*+,-012345689@ABCDEFGHIJKLMNPQRSTUVXYZ[`abcdefhijklmpqr"; + private const byte RunMarker = 0x90; + + private static readonly sbyte[] Reverse = BuildReverse(); + + private static sbyte[] BuildReverse() + { + var table = new sbyte[128]; + Array.Fill(table, (sbyte)-1); + for (int i = 0; i < Alphabet.Length; i++) + table[Alphabet[i]] = (sbyte)i; + return table; + } + + /// El texto parece BinHex 4.0 (tiene la linea de aviso). + public static bool LooksLikeBinHex(ReadOnlySpan start) => + Encoding.ASCII.GetString(start).Contains(Banner, StringComparison.Ordinal); + + /// Decodifica; devuelve el fichero y su data fork, o null si no es BinHex valido (CRC incluidos). + public static (MacFile File, byte[] DataFork)? Decode(byte[] text) + { + string s = Encoding.Latin1.GetString(text); + int banner = s.IndexOf(Banner, StringComparison.Ordinal); + if (banner < 0) + return null; + int start = s.IndexOf(':', banner + Banner.Length); + if (start < 0) + return null; + int end = s.IndexOf(':', start + 1); + if (end < 0) + return null; + + // 6 bits por caracter; los saltos de linea y espacios se ignoran. + var packed = new List(end - start); + int acc = 0, bits = 0; + for (int i = start + 1; i < end; i++) + { + char c = s[i]; + if (c is '\r' or '\n' or ' ' or '\t') + continue; + if (c >= 128 || Reverse[c] < 0) + return null; + acc = (acc << 6) | (byte)Reverse[c]; + bits += 6; + if (bits >= 8) + { + bits -= 8; + packed.Add((byte)(acc >> bits)); + acc &= (1 << bits) - 1; + } + } + var data = Unrun(packed); + + int p = 0; + if (data.Count < 1) + return null; + int nameLength = data[p++]; + int headerLength = 1 + nameLength + 1 + 4 + 4 + 2 + 4 + 4; + if (data.Count < headerLength + 2) + return null; + var span = System.Runtime.InteropServices.CollectionsMarshal.AsSpan(data); + if (Crc(span[..headerLength]) != BinaryPrimitives.ReadUInt16BigEndian(span[headerLength..])) + return null; + var file = new MacFile { Name = Encodings.MacRoman.GetString(span.Slice(1, nameLength)) }; + p = 1 + nameLength + 1; // version + span.Slice(p, 8).CopyTo(file.FinderInfo); + p += 8; + file.FinderFlags = BinaryPrimitives.ReadUInt16BigEndian(span[p..]); + p += 2; + long dataLength = BinaryPrimitives.ReadUInt32BigEndian(span[p..]); + long resourceLength = BinaryPrimitives.ReadUInt32BigEndian(span[(p + 4)..]); + p += 8 + 2; + if (p + dataLength + 2 + resourceLength + 2 > data.Count) + return null; + var dataFork = span.Slice(p, (int)dataLength).ToArray(); + if (Crc(dataFork) != BinaryPrimitives.ReadUInt16BigEndian(span[(p + (int)dataLength)..])) + return null; + p += (int)dataLength + 2; + file.ResourceFork = span.Slice(p, (int)resourceLength).ToArray(); + if (Crc(file.ResourceFork) != BinaryPrimitives.ReadUInt16BigEndian(span[(p + (int)resourceLength)..])) + return null; + return (file, dataFork); + } + + public static byte[] Encode(MacFile file, ReadOnlySpan dataFork) + { + var name = Encodings.MacRoman.GetBytes(MacNames.Shorten(file.Name, 63)); + var raw = new List(dataFork.Length + file.ResourceFork.Length + 128) { (byte)name.Length }; + raw.AddRange(name); + raw.Add(0); + raw.AddRange(file.FinderInfo.AsSpan(0, 8).ToArray()); + AddU16(raw, file.FinderFlags); + AddU32(raw, (uint)dataFork.Length); + AddU32(raw, (uint)file.ResourceFork.Length); + AddU16(raw, Crc(System.Runtime.InteropServices.CollectionsMarshal.AsSpan(raw))); + raw.AddRange(dataFork.ToArray()); + AddU16(raw, Crc(dataFork)); + raw.AddRange(file.ResourceFork); + AddU16(raw, Crc(file.ResourceFork)); + + var run = Run(raw); + var sb = new StringBuilder(Banner).Append("\r\n\r\n:"); + int column = 1, acc = 0, bits = 0; + void Emit(int value) + { + sb.Append(Alphabet[value]); + if (++column == 64) + { + sb.Append("\r\n"); + column = 0; + } + } + foreach (byte b in run) + { + acc = (acc << 8) | b; + bits += 8; + while (bits >= 6) + { + bits -= 6; + Emit((acc >> bits) & 0x3F); + } + acc &= (1 << bits) - 1; + } + if (bits > 0) + Emit((acc << (6 - bits)) & 0x3F); + sb.Append(":\r\n"); + return Encoding.ASCII.GetBytes(sb.ToString()); + } + + /// CRC de BinHex: CRC-16/XMODEM del bloque seguido de dos bytes a cero. + internal static ushort Crc(ReadOnlySpan data) => Crc16.Compute([0, 0], Crc16.Compute(data)); + + /// RLE de BinHex: 0x90 n repite n-1 veces el byte anterior; 0x90 0x00 es un 0x90 literal. + private static List Unrun(List input) + { + var output = new List(input.Count * 2); + for (int i = 0; i < input.Count; i++) + { + byte b = input[i]; + if (b != RunMarker) + { + output.Add(b); + continue; + } + if (++i >= input.Count) + break; + int count = input[i]; + if (count == 0) + output.Add(RunMarker); + else if (output.Count > 0) + for (int k = 1; k < count; k++) + output.Add(output[^1]); + } + return output; + } + + private static List Run(List input) + { + var output = new List(input.Count + input.Count / 10); + int i = 0; + while (i < input.Count) + { + byte b = input[i]; + int n = 1; + while (i + n < input.Count && input[i + n] == b && n < 255) + n++; + output.Add(b); + if (b == RunMarker) + output.Add(0); + if (n > 2) + { + output.Add(RunMarker); + output.Add((byte)n); + } + else if (n == 2) + { + output.Add(b); + if (b == RunMarker) + output.Add(0); + } + i += n; + } + return output; + } + + private static void AddU16(List b, ushort v) + { + b.Add((byte)(v >> 8)); + b.Add((byte)v); + } + + private static void AddU32(List b, uint v) + { + AddU16(b, (ushort)(v >> 16)); + AddU16(b, (ushort)v); + } +} diff --git a/src/Sunfish.Core/Mac/MacBinary.cs b/src/Sunfish.Core/Mac/MacBinary.cs new file mode 100644 index 0000000..fca2903 --- /dev/null +++ b/src/Sunfish.Core/Mac/MacBinary.cs @@ -0,0 +1,153 @@ +using System.Buffers.Binary; + +namespace Sunfish.Mac; + +/// +/// MacBinary I, II y III: cabecera de 128 bytes, data fork y resource fork, cada uno rellenado +/// hasta multiplo de 128. Se lee y se escribe en flujo: el data fork puede ser muy grande. +/// +public static class MacBinary +{ + public const int HeaderSize = 128; + public const int MaxResourceFork = 64 * 1024 * 1024; + + public enum Kind { None, MacBinary1, MacBinary2, MacBinary3 } + + /// Cabecera ya interpretada. + public sealed record Header(MacFile File, long DataLength, long ResourceLength, int SecondaryHeader, int CommentLength, Kind Kind); + + /// + /// Reconoce una cabecera MacBinary. II y III llevan CRC y la deteccion es fiable; MacBinary I + /// no tiene CRC y solo se acepta si se pide () y todo cuadra. + /// + public static Header? Parse(ReadOnlySpan h, long totalLength, bool acceptVersion1) + { + if (h.Length < HeaderSize || h[0] != 0 || h[74] != 0) + return null; + int nameLength = h[1]; + if (nameLength is < 1 or > 63) + return null; + long dataLength = BinaryPrimitives.ReadUInt32BigEndian(h[83..]); + long resourceLength = BinaryPrimitives.ReadUInt32BigEndian(h[87..]); + int secondary = BinaryPrimitives.ReadUInt16BigEndian(h[120..]); + if (resourceLength > MaxResourceFork) + return null; + + Kind kind; + ushort storedCrc = BinaryPrimitives.ReadUInt16BigEndian(h[124..]); + if (h[82] == 0 && storedCrc == Crc16.Compute(h[..124]) && storedCrc != 0) + kind = h[102] == 'm' && h[103] == 'B' && h[104] == 'I' && h[105] == 'N' ? Kind.MacBinary3 : Kind.MacBinary2; + else if (acceptVersion1 && h[82] == 0 && h[101] == 0 && h[122] == 0 && h[123] == 0 && storedCrc == 0) + kind = Kind.MacBinary1; + else + return null; + + int commentLength = kind == Kind.MacBinary1 ? 0 : BinaryPrimitives.ReadUInt16BigEndian(h[99..]); + long expected = HeaderSize + Pad(secondary) + Pad(dataLength) + Pad(resourceLength); + // El ultimo bloque puede venir sin relleno; el comentario de Get Info, si lo hay, va al final. + long minimum = HeaderSize + Pad(secondary) + Pad(dataLength) + resourceLength; + if (resourceLength == 0) + minimum = HeaderSize + Pad(secondary) + dataLength; + if (totalLength < minimum || totalLength > expected + Pad(commentLength) + 128) + return null; + + var file = new MacFile { Name = Encodings.MacRoman.GetString(h.Slice(2, nameLength)) }; + h.Slice(65, 8).CopyTo(file.FinderInfo); // tipo y creador + file.FinderInfo[8] = h[73]; // indicadores del Finder (byte alto) + file.FinderInfo[9] = kind == Kind.MacBinary1 ? (byte)0 : h[101]; // (byte bajo, II+) + h.Slice(75, 4).CopyTo(file.FinderInfo.AsSpan(10)); // posicion en la ventana + h.Slice(79, 2).CopyTo(file.FinderInfo.AsSpan(14)); // carpeta + if (kind == Kind.MacBinary3) + { + file.FinderInfo[24] = h[106]; // fdScript + file.FinderInfo[25] = h[107]; // fdXFlags + } + file.Created = MacFile.FromMacSeconds(BinaryPrimitives.ReadUInt32BigEndian(h[91..])); + file.Modified = MacFile.FromMacSeconds(BinaryPrimitives.ReadUInt32BigEndian(h[95..])); + return new Header(file, dataLength, resourceLength, secondary, commentLength, kind); + } + + /// + /// Separa un MacBinary: el data fork va a y lo demas se devuelve + /// en el (resource fork incluido). + /// + public static async Task DecodeAsync(Stream input, Header header, Stream dataFork, CancellationToken ct) + { + await SkipAsync(input, HeaderSize + Pad(header.SecondaryHeader), ct); + await CopyExactAsync(input, dataFork, header.DataLength, ct); + await SkipAsync(input, Pad(header.DataLength) - header.DataLength, ct, allowShort: header.ResourceLength == 0); + var resource = new byte[header.ResourceLength]; + await input.ReadExactlyAsync(resource, ct); + header.File.ResourceFork = resource; + return header.File; + } + + /// Tamano total del MacBinary III que escribiria. + public static long EncodedLength(long dataLength, int resourceLength) => + HeaderSize + Pad(dataLength) + Pad(resourceLength); + + /// Escribe MacBinary III (con CRC: tambien lo leen los decodificadores de MacBinary II). + public static async Task EncodeAsync(MacFile file, Stream dataFork, long dataLength, Stream output, CancellationToken ct) + { + await output.WriteAsync(BuildHeader(file, dataLength), ct); + await CopyExactAsync(dataFork, output, dataLength, ct); + await output.WriteAsync(new byte[Pad(dataLength) - dataLength], ct); + await output.WriteAsync(file.ResourceFork, ct); + await output.WriteAsync(new byte[Pad(file.ResourceFork.Length) - file.ResourceFork.Length], ct); + } + + internal static byte[] BuildHeader(MacFile file, long dataLength) + { + var h = new byte[HeaderSize]; + var name = Encodings.MacRoman.GetBytes(MacNames.Shorten(file.Name, 31)); + h[1] = (byte)name.Length; + name.CopyTo(h, 2); + file.FinderInfo.AsSpan(0, 8).CopyTo(h.AsSpan(65)); + h[73] = (byte)(file.FinderFlags >> 8); + file.FinderInfo.AsSpan(10, 4).CopyTo(h.AsSpan(75)); + file.FinderInfo.AsSpan(14, 2).CopyTo(h.AsSpan(79)); + BinaryPrimitives.WriteUInt32BigEndian(h.AsSpan(83), (uint)dataLength); + BinaryPrimitives.WriteUInt32BigEndian(h.AsSpan(87), (uint)file.ResourceFork.Length); + BinaryPrimitives.WriteUInt32BigEndian(h.AsSpan(91), MacFile.ToMacSeconds(file.Created ?? file.Modified)); + BinaryPrimitives.WriteUInt32BigEndian(h.AsSpan(95), MacFile.ToMacSeconds(file.Modified)); + h[101] = (byte)(file.FinderFlags & 0xFF); + "mBIN"u8.CopyTo(h.AsSpan(102)); + h[106] = file.FinderInfo[24]; + h[107] = file.FinderInfo[25]; + h[122] = 130; // MacBinary III + h[123] = 129; // basta un lector de MacBinary II + BinaryPrimitives.WriteUInt16BigEndian(h.AsSpan(124), Crc16.Compute(h.AsSpan(0, 124))); + return h; + } + + internal static long Pad(long length) => (length + 127) / 128 * 128; + + private static async Task SkipAsync(Stream input, long count, CancellationToken ct, bool allowShort = false) + { + var buffer = new byte[Math.Min(65536, Math.Max(1, count))]; + while (count > 0) + { + int n = await input.ReadAsync(buffer.AsMemory(0, (int)Math.Min(buffer.Length, count)), ct); + if (n == 0) + { + if (allowShort) + return; + throw new EndOfStreamException(); + } + count -= n; + } + } + + private static async Task CopyExactAsync(Stream input, Stream output, long count, CancellationToken ct) + { + var buffer = new byte[65536]; + while (count > 0) + { + int n = await input.ReadAsync(buffer.AsMemory(0, (int)Math.Min(buffer.Length, count)), ct); + if (n == 0) + throw new EndOfStreamException(); + await output.WriteAsync(buffer.AsMemory(0, n), ct); + count -= n; + } + } +} diff --git a/src/Sunfish.Core/Mac/MacFile.cs b/src/Sunfish.Core/Mac/MacFile.cs new file mode 100644 index 0000000..344cfd5 --- /dev/null +++ b/src/Sunfish.Core/Mac/MacFile.cs @@ -0,0 +1,68 @@ +using System.Buffers.Binary; +using System.Text; + +namespace Sunfish.Mac; + +/// +/// Un fichero de Mac clasico separado en sus partes: nombre, Finder Info (tipo, creador, +/// indicadores), fechas, data fork y resource fork. Es lo que comparten MacBinary, BinHex y +/// AppleDouble. +/// +public sealed class MacFile +{ + public string Name { get; set; } = ""; + + /// Los 32 bytes de Finder Info (FInfo + FXInfo) tal como los guarda AppleDouble. + public byte[] FinderInfo { get; set; } = new byte[32]; + + public byte[] ResourceFork { get; set; } = []; + + public DateTime? Created { get; set; } + public DateTime? Modified { get; set; } + + public string Type + { + get => Encoding.Latin1.GetString(FinderInfo, 0, 4); + set => Encoding.Latin1.GetBytes(Fit4(value)).CopyTo(FinderInfo, 0); + } + + public string Creator + { + get => Encoding.Latin1.GetString(FinderInfo, 4, 4); + set => Encoding.Latin1.GetBytes(Fit4(value)).CopyTo(FinderInfo, 4); + } + + public ushort FinderFlags + { + get => BinaryPrimitives.ReadUInt16BigEndian(FinderInfo.AsSpan(8)); + set => BinaryPrimitives.WriteUInt16BigEndian(FinderInfo.AsSpan(8), value); + } + + /// Hay algo que el data fork solo no conserva (resource fork, tipo, creador o indicadores). + public bool HasMacData => ResourceFork.Length > 0 || FinderInfo.Any(b => b != 0); + + private static string Fit4(string s) => (s + " ")[..4]; + + /// Segundos desde el 1 de enero de 1904 (las fechas del Mac clasico). + internal static readonly DateTime MacEpoch = new(1904, 1, 1, 0, 0, 0, DateTimeKind.Local); + + internal static DateTime? FromMacSeconds(uint seconds) => seconds == 0 ? null : MacEpoch.AddSeconds(seconds); + + internal static uint ToMacSeconds(DateTime? time) => + time is { } t && t > MacEpoch ? (uint)Math.Min(uint.MaxValue, ((t.Kind == DateTimeKind.Utc ? t.ToLocalTime() : t) - MacEpoch).TotalSeconds) : 0; +} + +/// CRC-16/XMODEM (CCITT, polinomio 0x1021, inicio 0): el de MacBinary II/III y BinHex. +internal static class Crc16 +{ + public static ushort Compute(ReadOnlySpan data, ushort crc = 0) + { + foreach (byte b in data) + { + crc ^= (ushort)(b << 8); + for (int i = 0; i < 8; i++) + crc = (ushort)((crc & 0x8000) != 0 ? (crc << 1) ^ 0x1021 : crc << 1); + } + return crc; + } +} diff --git a/src/Sunfish.Core/Mac/MacFiles.cs b/src/Sunfish.Core/Mac/MacFiles.cs new file mode 100644 index 0000000..d6cfc55 --- /dev/null +++ b/src/Sunfish.Core/Mac/MacFiles.cs @@ -0,0 +1,120 @@ +namespace Sunfish.Mac; + +/// +/// Mac clasico de forma transparente sobre el disco (DOC/MAC-CLASICO.md): al subir, un MacBinary +/// o un BinHex validos se separan en "nombre" + "._nombre" (AppleDouble); al descargar se vuelven +/// a juntar. Si el fichero no tiene nada propio de Mac no se toca. +/// +public static class MacFiles +{ + /// Limite para BinHex (se decodifica en memoria). + public const long MaxBinHex = 256L * 1024 * 1024; + + public enum Unpacked { NotMac, Unpacked, ExistsKept } + + /// + /// Si el fichero recien subido es MacBinary (II/III, o I si ) + /// o BinHex con CRC correctos, lo separa en data fork + AppleDouble con el nombre que trae. + /// Si ese nombre ya existe y no se permite sobrescribir, deja el fichero codificado como + /// estaba (no se pierde nada). Devuelve que ha pasado y el nombre final. + /// + public static async Task<(Unpacked Result, string Name)> TryUnpackAsync(string path, bool overwrite, bool acceptVersion1, CancellationToken ct) + { + string directory = Path.GetDirectoryName(path)!; + string original = Path.GetFileName(path); + var info = new FileInfo(path); + if (!info.Exists || info.Length < MacBinary.HeaderSize) + return (Unpacked.NotMac, original); + + var head = new byte[MacBinary.HeaderSize]; + await using (var probe = File.OpenRead(path)) + await probe.ReadExactlyAsync(head, ct); + + bool v1 = acceptVersion1 && original.EndsWith(".bin", StringComparison.OrdinalIgnoreCase); + if (MacBinary.Parse(head, info.Length, v1) is { } header) + { + string name = SafeName(header.File.Name, original, ".bin"); + string target = Path.Combine(directory, name); + if (!CanWrite(target, path, overwrite)) + return (Unpacked.ExistsKept, original); + string temp = Path.Combine(directory, Shares.FileOperations.TempPrefix + Guid.NewGuid().ToString("N") + ".tmp"); + MacFile file; + await using (var input = File.OpenRead(path)) + await using (var output = File.Create(temp)) + file = await MacBinary.DecodeAsync(input, header, output, ct); + Finish(path, temp, target, file); + return (Unpacked.Unpacked, name); + } + + if (info.Length <= MaxBinHex && BinHex.LooksLikeBinHex(await ReadStartAsync(path, ct)) && + BinHex.Decode(await File.ReadAllBytesAsync(path, ct)) is { } hqx) + { + string name = SafeName(hqx.File.Name, original, ".hqx"); + string target = Path.Combine(directory, name); + if (!CanWrite(target, path, overwrite)) + return (Unpacked.ExistsKept, original); + string temp = Path.Combine(directory, Shares.FileOperations.TempPrefix + Guid.NewGuid().ToString("N") + ".tmp"); + await File.WriteAllBytesAsync(temp, hqx.DataFork, ct); + Finish(path, temp, target, hqx.File); + return (Unpacked.Unpacked, name); + } + return (Unpacked.NotMac, original); + } + + private static async Task ReadStartAsync(string path, CancellationToken ct) + { + var buffer = new byte[4096]; + await using var stream = File.OpenRead(path); + int n = await stream.ReadAtLeastAsync(buffer, buffer.Length, throwOnEndOfStream: false, ct); + return buffer[..n]; + } + + private static bool CanWrite(string target, string source, bool overwrite) => + string.Equals(target, source, StringComparison.OrdinalIgnoreCase) || overwrite || (!File.Exists(target) && !Directory.Exists(target)); + + /// Coloca el data fork, escribe el AppleDouble si hace falta y borra el codificado. + private static void Finish(string encoded, string temp, string target, MacFile file) + { + File.Move(temp, target, overwrite: true); + if (file.Modified is { } modified) + File.SetLastWriteTime(target, modified); + string companion = AppleDouble.CompanionOf(target); + if (file.HasMacData) + File.WriteAllBytes(companion, AppleDouble.Write(file)); + else if (File.Exists(companion)) + File.Delete(companion); + if (!string.Equals(encoded, target, StringComparison.OrdinalIgnoreCase)) + File.Delete(encoded); + } + + /// + /// Nombre de destino a partir del que trae la cabecera: valido en este sistema; si no lo es, + /// el del fichero subido sin la extension de codificacion. + /// + internal static string SafeName(string macName, string uploaded, string encodingExtension) + { + string clean = new string(macName.Select(c => c is '/' or '\\' or ':' || char.IsControl(c) ? '_' : c).ToArray()).Trim().TrimEnd('.'); + if (Shares.FileOperations.IsCreatableName(clean)) + return clean; + string fallback = uploaded.EndsWith(encodingExtension, StringComparison.OrdinalIgnoreCase) ? uploaded[..^encodingExtension.Length] : uploaded; + return Shares.FileOperations.IsCreatableName(fallback) ? fallback : uploaded; + } + + /// El fichero con su AppleDouble, o con tipo y creador por extension si no lo tiene. + public static MacFile Describe(string physical) + { + var file = AppleDouble.ReadCompanion(physical) ?? new MacFile(); + file.Name = Path.GetFileName(physical); + if (file.FinderInfo.Take(8).All(b => b == 0)) + { + var (type, creator) = MacNames.TypeFor(file.Name); + file.Type = type; + file.Creator = creator; + } + file.Modified ??= File.GetLastWriteTime(physical); + return file; + } + + /// Tiene un AppleDouble con resource fork o datos del Finder. + public static bool HasCompanion(string physical) => File.Exists(AppleDouble.CompanionOf(physical)); +} diff --git a/src/Sunfish.Core/Mac/MacNames.cs b/src/Sunfish.Core/Mac/MacNames.cs new file mode 100644 index 0000000..3445479 --- /dev/null +++ b/src/Sunfish.Core/Mac/MacNames.cs @@ -0,0 +1,41 @@ +namespace Sunfish.Mac; + +/// Nombres y tipos para Mac clasico. +public static class MacNames +{ + /// + /// Acorta un nombre a caracteres (HFS admite 31) conservando la + /// extension: "Un nombre muy largo de verdad.jpg" -> "Un nombre muy largo de ve….jpg". + /// + public static string Shorten(string name, int max) + { + if (name.Length <= max) + return name; + string ext = Path.GetExtension(name); + if (ext.Length == 0 || ext.Length > 6 || ext.Length >= max - 2) + return name[..(max - 1)] + "…"; + return name[..(max - ext.Length - 1)] + "…" + ext; + } + + /// Tipo y creador por extension para ficheros sin AppleDouble (lo que esperan los Mac clasicos). + private static readonly Dictionary Types = new(StringComparer.OrdinalIgnoreCase) + { + [".txt"] = ("TEXT", "ttxt"), [".text"] = ("TEXT", "ttxt"), [".nfo"] = ("TEXT", "ttxt"), [".md"] = ("TEXT", "ttxt"), + [".htm"] = ("TEXT", "MOSS"), [".html"] = ("TEXT", "MOSS"), [".css"] = ("TEXT", "ttxt"), [".c"] = ("TEXT", "ttxt"), + [".h"] = ("TEXT", "ttxt"), [".pas"] = ("TEXT", "ttxt"), [".rtf"] = ("TEXT", "MSWD"), + [".gif"] = ("GIFf", "ogle"), [".jpg"] = ("JPEG", "ogle"), [".jpeg"] = ("JPEG", "ogle"), [".png"] = ("PNGf", "ogle"), + [".pct"] = ("PICT", "ttxt"), [".pict"] = ("PICT", "ttxt"), [".tif"] = ("TIFF", "ogle"), [".tiff"] = ("TIFF", "ogle"), + [".bmp"] = ("BMPp", "ogle"), + [".pdf"] = ("PDF ", "CARO"), [".ps"] = ("TEXT", "ttxt"), + [".sit"] = ("SIT!", "SITx"), [".sea"] = ("APPL", "????"), [".cpt"] = ("PACT", "CPCT"), [".hqx"] = ("TEXT", "SITx"), + [".bin"] = ("BINA", "SITx"), [".zip"] = ("ZIP ", "SITx"), [".gz"] = ("Gzip", "SITx"), [".tar"] = ("TARF", "SITx"), + [".img"] = ("rohd", "ddsk"), [".dsk"] = ("dImg", "dCpy"), [".image"] = ("dImg", "dCpy"), [".iso"] = ("rodh", "ddsk"), + [".mp3"] = ("MPG3", "TVOD"), [".aif"] = ("AIFF", "TVOD"), [".aiff"] = ("AIFF", "TVOD"), [".wav"] = ("WAVE", "TVOD"), + [".mov"] = ("MooV", "TVOD"), [".mid"] = ("Midi", "TVOD"), [".mod"] = ("MODf", "TVOD"), + [".doc"] = ("W8BN", "MSWD"), [".xls"] = ("XLS8", "XCEL"), + }; + + /// Tipo y creador para un nombre ("????" si no se conoce la extension). + public static (string Type, string Creator) TypeFor(string name) => + Types.TryGetValue(Path.GetExtension(name), out var t) ? t : ("BINA", "????"); +} diff --git a/src/Sunfish.Core/Net/AddressRules.cs b/src/Sunfish.Core/Net/AddressRules.cs new file mode 100644 index 0000000..9428e5a --- /dev/null +++ b/src/Sunfish.Core/Net/AddressRules.cs @@ -0,0 +1,64 @@ +using System.Net; +using System.Net.Sockets; + +namespace Sunfish.Net; + +/// +/// Listas de direcciones como AllowFrom: IP sueltas ("127.0.0.1", "::1"), redes en notacion CIDR +/// ("192.168.1.0/24", "fd00::/8") o "*" (cualquiera). Las IPv4 que llegan como IPv6 +/// (::ffff:a.b.c.d) se comparan como IPv4. +/// +public static class AddressRules +{ + public static bool IsValid(string rule) => rule == "*" || TryParse(rule, out _, out _); + + public static bool Matches(IEnumerable rules, IPAddress? address) + { + if (address is null) + return false; + if (address.IsIPv4MappedToIPv6) + address = address.MapToIPv4(); + foreach (var rule in rules) + { + if (rule == "*") + return true; + if (TryParse(rule, out var network, out int prefix) && InNetwork(address, network, prefix)) + return true; + } + return false; + } + + private static bool TryParse(string rule, out IPAddress network, out int prefix) + { + network = IPAddress.None; + prefix = 0; + string text = rule.Trim(); + int slash = text.IndexOf('/'); + if (!IPAddress.TryParse(slash < 0 ? text : text[..slash], out var parsed)) + return false; + network = parsed.IsIPv4MappedToIPv6 ? parsed.MapToIPv4() : parsed; + int max = network.AddressFamily == AddressFamily.InterNetwork ? 32 : 128; + if (slash < 0) + { + prefix = max; + return true; + } + return int.TryParse(text[(slash + 1)..], out prefix) && prefix >= 0 && prefix <= max; + } + + private static bool InNetwork(IPAddress address, IPAddress network, int prefix) + { + if (address.AddressFamily != network.AddressFamily) + return false; + var a = address.GetAddressBytes(); + var n = network.GetAddressBytes(); + int full = prefix / 8, rest = prefix % 8; + for (int i = 0; i < full; i++) + if (a[i] != n[i]) + return false; + if (rest == 0) + return true; + int mask = 0xFF << (8 - rest) & 0xFF; + return (a[full] & mask) == (n[full] & mask); + } +} diff --git a/src/Sunfish.Core/Net/Discovery.cs b/src/Sunfish.Core/Net/Discovery.cs new file mode 100644 index 0000000..1d66581 --- /dev/null +++ b/src/Sunfish.Core/Net/Discovery.cs @@ -0,0 +1,265 @@ +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using Sunfish.Log; + +namespace Sunfish.Net; + +/// Un servidor encontrado en la red. +public sealed record DiscoveredServer(string Name, IPAddress Address, int Port, string Path, bool IsSunfish) +{ + public string Url => LocalAddresses.HttpUrl(Address, Port) .TrimEnd('/') + (Path.StartsWith('/') ? Path : "/" + Path); +} + +/// Lo que un Sunfish anuncia. +public sealed record AnnouncedServices(string Name, int HttpPort, bool WebDav, int FtpPort); + +/// +/// DNS-SD sobre mDNS (RFC 6762/6763), sin bibliotecas externas. +/// El anunciante escucha en 224.0.0.251:5353 (compartido con Avahi, Bonjour o el mDNS de Windows +/// gracias a ReuseAddress) y responde por _http._tcp, _webdav._tcp y _ftp._tcp. Si no puede abrir +/// el puerto 5353 lo anota y sigue sin anunciar. La busqueda pregunta desde un puerto cualquiera: +/// los respondedores contestan directamente a ese puerto ("legacy unicast"). +/// +public static class Discovery +{ + public const int Port = 5353; + public static readonly IPAddress Group = IPAddress.Parse("224.0.0.251"); + private static readonly IPEndPoint GroupEndPoint = new(Group, Port); + + public const string HttpService = "_http._tcp.local"; + public const string WebDavService = "_webdav._tcp.local"; + public const string FtpService = "_ftp._tcp.local"; + private const string ServicesQuery = "_services._dns-sd._udp.local"; + + /// Busca servidores WebDAV en la LAN durante . + public static async Task> BrowseAsync(TimeSpan wait, CancellationToken ct = default) + { + var found = new Dictionary(StringComparer.OrdinalIgnoreCase); + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + socket.Bind(new IPEndPoint(IPAddress.Any, 0)); + var query = new DnsMessage { Id = (ushort)Random.Shared.Next(1, 65535) }; + query.Questions.Add((WebDavService, DnsType.Ptr, true)); + byte[] packet = query.Encode(); + + // Por cada interfaz IPv4 activa, para llegar a todas las redes del equipo. + foreach (var local in LocalIPv4()) + { + try + { + socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastInterface, local.GetAddressBytes()); + await socket.SendToAsync(packet, SocketFlags.None, GroupEndPoint, ct); + } + catch (SocketException) { } + } + + var records = new List(); + var sources = new Dictionary(StringComparer.OrdinalIgnoreCase); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(wait); + var buffer = new byte[9000]; + try + { + while (true) + { + var result = await socket.ReceiveFromAsync(buffer, SocketFlags.None, new IPEndPoint(IPAddress.Any, 0), timeout.Token); + var message = DnsMessage.Decode(buffer.AsSpan(0, result.ReceivedBytes)); + if (message is null || !message.IsResponse) + continue; + var all = message.Answers.Concat(message.Additional).ToList(); + records.AddRange(all); + foreach (var r in all.Where(r => r.Type == DnsType.Srv)) + sources[r.Name] = ((IPEndPoint)result.RemoteEndPoint).Address; + } + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) { } + + foreach (var ptr in records.Where(r => r.Type == DnsType.Ptr && r.Name.Equals(WebDavService, StringComparison.OrdinalIgnoreCase))) + { + string instance = ptr.Target!; + var srv = records.LastOrDefault(r => r.Type == DnsType.Srv && r.Name.Equals(instance, StringComparison.OrdinalIgnoreCase)); + if (srv is null) + continue; + var txt = records.LastOrDefault(r => r.Type == DnsType.Txt && r.Name.Equals(instance, StringComparison.OrdinalIgnoreCase)); + var address = records.LastOrDefault(r => r.Type == DnsType.A && r.Name.Equals(srv.Target, StringComparison.OrdinalIgnoreCase))?.Address + ?? sources.GetValueOrDefault(instance); + if (address is null) + continue; + string path = txt?.Text.FirstOrDefault(t => t.StartsWith("path=", StringComparison.Ordinal))?[5..] ?? "/"; + bool sunfish = txt?.Text.Any(t => t.StartsWith("sunfish=", StringComparison.Ordinal)) == true; + string name = instance.EndsWith("." + WebDavService, StringComparison.OrdinalIgnoreCase) + ? instance[..^(WebDavService.Length + 1)] + : instance; + found[instance] = new DiscoveredServer(name, address, srv.Port, path, sunfish); + } + return found.Values.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase).ToList(); + } + + internal static List LocalIPv4() => + LocalAddresses.Get().Select(a => a.Address).Where(a => a.AddressFamily == AddressFamily.InterNetwork).ToList(); +} + +/// Responde a las preguntas mDNS sobre este Sunfish mientras esta en marcha. +public sealed class MdnsAnnouncer : IAsyncDisposable +{ + private readonly ServerLog _log; + private readonly Socket _socket; + private readonly CancellationTokenSource _stop = new(); + private readonly Task _loop; + private volatile AnnouncedServices _services; + private readonly string _host; + private readonly string _instance; + + private MdnsAnnouncer(Socket socket, AnnouncedServices services, ServerLog log) + { + _socket = socket; + _services = services; + _log = log; + string machine = new string(Environment.MachineName.Where(c => char.IsAsciiLetterOrDigit(c) || c == '-').ToArray()); + if (machine.Length == 0) + machine = "pc"; + _host = $"sunfish-{machine.ToLowerInvariant()}.local"; + _instance = $"{services.Name} ({machine})"; + _loop = ReceiveLoopAsync(); + } + + /// Empieza a anunciar; null si no se puede abrir el puerto 5353 (queda anotado). + public static MdnsAnnouncer? Start(AnnouncedServices services, ServerLog log) + { + var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + try + { + socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + socket.Bind(new IPEndPoint(IPAddress.Any, Discovery.Port)); + foreach (var local in Discovery.LocalIPv4()) + { + try + { + socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.AddMembership, new MulticastOption(Discovery.Group, local)); + } + catch (SocketException) { } + } + socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 255); + socket.MulticastLoopback = true; + } + catch (SocketException e) + { + socket.Dispose(); + log.Warning(Strings.MdnsUnavailable(e.Message)); + return null; + } + var announcer = new MdnsAnnouncer(socket, services, log); + _ = announcer.AnnounceAsync(); // anuncio no solicitado al arrancar (dos veces, RFC 6762 8.3) + log.Info(Strings.MdnsAnnouncing(announcer._instance)); + return announcer; + } + + public void Update(AnnouncedServices services) + { + _services = services; + _ = AnnounceAsync(); + } + + private async Task AnnounceAsync() + { + for (int i = 0; i < 2 && !_stop.IsCancellationRequested; i++) + { + await SendAsync(BuildResponse(null, 0, null), Discovery.Group, Discovery.Port); + try { await Task.Delay(1000, _stop.Token); } catch (OperationCanceledException) { return; } + } + } + + private async Task ReceiveLoopAsync() + { + var buffer = new byte[9000]; + while (!_stop.IsCancellationRequested) + { + SocketReceiveFromResult result; + try + { + result = await _socket.ReceiveFromAsync(buffer, SocketFlags.None, new IPEndPoint(IPAddress.Any, 0), _stop.Token); + } + catch (OperationCanceledException) { return; } + catch (ObjectDisposedException) { return; } + catch (SocketException) { continue; } + + var query = DnsMessage.Decode(buffer.AsSpan(0, result.ReceivedBytes)); + if (query is null || query.IsResponse || query.Questions.Count == 0) + continue; + var remote = (IPEndPoint)result.RemoteEndPoint; + var response = BuildResponse(query.Questions.Select(q => (q.Name, q.Type)).ToList(), query.Id, remote); + if (response is null) + continue; + // Legacy unicast: quien pregunta desde otro puerto recibe la respuesta en ese puerto. + if (remote.Port != Discovery.Port || query.Questions.Any(q => q.UnicastResponse)) + await SendAsync(response, remote.Address, remote.Port); + else + await SendAsync(response, Discovery.Group, Discovery.Port); + } + } + + /// Respuesta para las preguntas dadas (null = anuncio completo); null si no nos toca. + private byte[]? BuildResponse(List<(string Name, DnsType Type)>? questions, ushort id, IPEndPoint? remote) + { + var s = _services; + var types = new List<(string Type, int Port, string[] Txt)>(); + if (s.HttpPort > 0) + types.Add((Discovery.HttpService, s.HttpPort, ["sunfish=3", "path=/"])); + if (s.HttpPort > 0 && s.WebDav) + types.Add((Discovery.WebDavService, s.HttpPort, ["sunfish=3", "path=/"])); + if (s.FtpPort > 0) + types.Add((Discovery.FtpService, s.FtpPort, ["sunfish=3", "path=/"])); + if (types.Count == 0) + return null; + + var message = new DnsMessage { Id = remote is not null && remote.Port != Discovery.Port ? id : (ushort)0, IsResponse = true }; + bool answered = false; + foreach (var (type, port, txt) in types) + { + string instance = $"{_instance}.{type}"; + bool wanted = questions is null || questions.Any(q => + (q.Name.Equals(type, StringComparison.OrdinalIgnoreCase) && q.Type is DnsType.Ptr or DnsType.Any) || + q.Name.Equals(instance, StringComparison.OrdinalIgnoreCase)); + if (questions is not null && questions.Any(q => q.Name.Equals("_services._dns-sd._udp.local", StringComparison.OrdinalIgnoreCase))) + { + message.Answers.Add(new DnsRecord("_services._dns-sd._udp.local", DnsType.Ptr, 4500) { Target = type }); + answered = true; + } + if (!wanted) + continue; + answered = true; + message.Answers.Add(new DnsRecord(type, DnsType.Ptr, 4500) { Target = instance }); + message.Additional.Add(new DnsRecord(instance, DnsType.Srv, 120) { Target = _host, Port = (ushort)port, CacheFlush = true }); + message.Additional.Add(new DnsRecord(instance, DnsType.Txt, 4500) { Text = txt, CacheFlush = true }); + } + if (questions is not null && questions.Any(q => q.Name.Equals(_host, StringComparison.OrdinalIgnoreCase))) + answered = true; + if (!answered) + return null; + foreach (var address in Discovery.LocalIPv4()) + message.Additional.Add(new DnsRecord(_host, DnsType.A, 120) { Address = address, CacheFlush = true }); + if (remote is not null && remote.Port != Discovery.Port) + foreach (var (name, type) in questions!) + message.Questions.Add((name, type, false)); + return message.Encode(); + } + + private async Task SendAsync(byte[]? packet, IPAddress address, int port) + { + if (packet is null) + return; + try + { + await _socket.SendToAsync(packet, SocketFlags.None, new IPEndPoint(address, port), _stop.Token); + } + catch (Exception e) when (e is SocketException or OperationCanceledException or ObjectDisposedException) { } + } + + public async ValueTask DisposeAsync() + { + _stop.Cancel(); + _socket.Dispose(); + try { await _loop; } catch (Exception e) when (e is OperationCanceledException or ObjectDisposedException) { } + _stop.Dispose(); + } +} diff --git a/src/Sunfish.Core/Net/DnsMessage.cs b/src/Sunfish.Core/Net/DnsMessage.cs new file mode 100644 index 0000000..013f518 --- /dev/null +++ b/src/Sunfish.Core/Net/DnsMessage.cs @@ -0,0 +1,223 @@ +using System.Buffers.Binary; +using System.Net; +using System.Text; + +namespace Sunfish.Net; + +public enum DnsType : ushort { A = 1, Ptr = 12, Txt = 16, Aaaa = 28, Srv = 33, Any = 255 } + +/// Un registro de recurso DNS (solo los tipos que usa DNS-SD). +public sealed record DnsRecord(string Name, DnsType Type, uint Ttl) +{ + public string? Target { get; init; } // PTR, SRV + public ushort Port { get; init; } // SRV + public IPAddress? Address { get; init; } // A, AAAA + public IReadOnlyList Text { get; init; } = []; // TXT + public bool CacheFlush { get; init; } +} + +/// +/// Mensaje DNS minimo para mDNS (RFC 6762) y DNS-SD (RFC 6763): preguntas y registros PTR, SRV, +/// TXT, A y AAAA. Al leer se entienden los nombres comprimidos; al escribir no se comprime. +/// +public sealed class DnsMessage +{ + public ushort Id { get; set; } + public bool IsResponse { get; set; } + public List<(string Name, DnsType Type, bool UnicastResponse)> Questions { get; } = new(); + public List Answers { get; } = new(); + public List Additional { get; } = new(); + + public byte[] Encode() + { + var b = new List(512); + void U16(int v) { b.Add((byte)(v >> 8)); b.Add((byte)v); } + void U32(uint v) { U16((int)(v >> 16)); U16((int)(v & 0xFFFF)); } + U16(Id); + U16(IsResponse ? 0x8400 : 0); // QR + AA + U16(Questions.Count); + U16(Answers.Count); + U16(0); + U16(Additional.Count); + foreach (var (name, type, unicast) in Questions) + { + WriteName(b, name); + U16((ushort)type); + U16(unicast ? 0x8001 : 0x0001); + } + foreach (var r in Answers.Concat(Additional)) + { + WriteName(b, r.Name); + U16((ushort)r.Type); + U16(r.CacheFlush ? 0x8001 : 0x0001); + U32(r.Ttl); + var data = new List(); + switch (r.Type) + { + case DnsType.Ptr: + WriteName(data, r.Target!); + break; + case DnsType.Srv: + data.AddRange([0, 0, 0, 0, (byte)(r.Port >> 8), (byte)r.Port]); + WriteName(data, r.Target!); + break; + case DnsType.Txt: + if (r.Text.Count == 0) + data.Add(0); + foreach (var item in r.Text) + { + var bytes = Encoding.UTF8.GetBytes(item); + data.Add((byte)Math.Min(255, bytes.Length)); + data.AddRange(bytes.Take(255)); + } + break; + case DnsType.A or DnsType.Aaaa: + data.AddRange(r.Address!.GetAddressBytes()); + break; + } + U16(data.Count); + b.AddRange(data); + } + return b.ToArray(); + } + + private static void WriteName(List b, string name) + { + foreach (var label in SplitLabels(name)) + { + var bytes = Encoding.UTF8.GetBytes(label); + b.Add((byte)Math.Min(63, bytes.Length)); + b.AddRange(bytes.Take(63)); + } + b.Add(0); + } + + /// + /// "Sunfish (equipo)._http._tcp.local" -> etiquetas. La primera de un nombre de instancia puede + /// contener puntos, asi que se separa por el tipo de servicio conocido. + /// + internal static List SplitLabels(string name) + { + name = name.TrimEnd('.'); + int service = name.IndexOf("._", StringComparison.Ordinal); + if (service > 0) + { + var labels = new List { name[..service] }; + labels.AddRange(name[(service + 1)..].Split('.')); + return labels; + } + return name.Split('.').ToList(); + } + + public static DnsMessage? Decode(ReadOnlySpan data) + { + try + { + var m = new DnsMessage + { + Id = BinaryPrimitives.ReadUInt16BigEndian(data), + IsResponse = (data[2] & 0x80) != 0, + }; + int qd = BinaryPrimitives.ReadUInt16BigEndian(data[4..]); + int an = BinaryPrimitives.ReadUInt16BigEndian(data[6..]); + int ns = BinaryPrimitives.ReadUInt16BigEndian(data[8..]); + int ar = BinaryPrimitives.ReadUInt16BigEndian(data[10..]); + int pos = 12; + for (int i = 0; i < qd; i++) + { + string name = ReadName(data, ref pos); + var type = (DnsType)BinaryPrimitives.ReadUInt16BigEndian(data[pos..]); + int cls = BinaryPrimitives.ReadUInt16BigEndian(data[(pos + 2)..]); + pos += 4; + m.Questions.Add((name, type, (cls & 0x8000) != 0)); + } + for (int i = 0; i < an + ns + ar; i++) + { + var record = ReadRecord(data, ref pos); + if (record is null) + continue; + if (i < an) + m.Answers.Add(record); + else + m.Additional.Add(record); + } + return m; + } + catch (Exception e) when (e is ArgumentOutOfRangeException or IndexOutOfRangeException or InvalidDataException) + { + return null; + } + } + + private static DnsRecord? ReadRecord(ReadOnlySpan data, ref int pos) + { + string name = ReadName(data, ref pos); + var type = (DnsType)BinaryPrimitives.ReadUInt16BigEndian(data[pos..]); + int cls = BinaryPrimitives.ReadUInt16BigEndian(data[(pos + 2)..]); + uint ttl = BinaryPrimitives.ReadUInt32BigEndian(data[(pos + 4)..]); + int length = BinaryPrimitives.ReadUInt16BigEndian(data[(pos + 8)..]); + pos += 10; + int start = pos; + int end = pos + length; + if (end > data.Length) + throw new InvalidDataException(); + pos = end; + var r = new DnsRecord(name, type, ttl) { CacheFlush = (cls & 0x8000) != 0 }; + int p = start; + switch (type) + { + case DnsType.Ptr: + return r with { Target = ReadName(data, ref p) }; + case DnsType.Srv: + p += 4; + ushort port = BinaryPrimitives.ReadUInt16BigEndian(data[p..]); + p += 2; + return r with { Port = port, Target = ReadName(data, ref p) }; + case DnsType.Txt: + var items = new List(); + while (p < end) + { + int len = data[p++]; + if (len > 0) + items.Add(Encoding.UTF8.GetString(data.Slice(p, Math.Min(len, end - p)))); + p += len; + } + return r with { Text = items }; + case DnsType.A when length == 4: + return r with { Address = new IPAddress(data.Slice(start, 4)) }; + case DnsType.Aaaa when length == 16: + return r with { Address = new IPAddress(data.Slice(start, 16)) }; + default: + return null; + } + } + + private static string ReadName(ReadOnlySpan data, ref int pos) + { + var labels = new List(); + int p = pos; + bool jumped = false; + for (int hops = 0; hops < 64; hops++) + { + int len = data[p]; + if (len == 0) + { + if (!jumped) + pos = p + 1; + return string.Join('.', labels); + } + if ((len & 0xC0) == 0xC0) + { + int target = ((len & 0x3F) << 8) | data[p + 1]; + if (!jumped) + pos = p + 2; + jumped = true; + p = target; + continue; + } + labels.Add(Encoding.UTF8.GetString(data.Slice(p + 1, len))); + p += len + 1; + } + throw new InvalidDataException("Name loop"); + } +} diff --git a/src/Sunfish.Core/Net/LocalAddresses.cs b/src/Sunfish.Core/Net/LocalAddresses.cs new file mode 100644 index 0000000..a0cd14f --- /dev/null +++ b/src/Sunfish.Core/Net/LocalAddresses.cs @@ -0,0 +1,64 @@ +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using Sunfish.Config; +using Sunfish.Web; + +namespace Sunfish.Net; + +/// Una direccion local util para dar a otros equipos. +public sealed record LocalAddress(string Interface, NetworkInterfaceType Type, IPAddress Address); + +public static class LocalAddresses +{ + /// Direcciones de las interfaces activas, sin loopback (IPv4 primero). + public static List Get() + { + var result = new List(); + NetworkInterface[] interfaces; + try + { + interfaces = NetworkInterface.GetAllNetworkInterfaces(); + } + catch (NetworkInformationException) + { + return result; + } + foreach (var nic in interfaces) + { + if (nic.OperationalStatus != OperationalStatus.Up || nic.NetworkInterfaceType == NetworkInterfaceType.Loopback) + continue; + foreach (var unicast in nic.GetIPProperties().UnicastAddresses) + { + var address = unicast.Address; + if (IPAddress.IsLoopback(address)) + continue; + if (address.AddressFamily == AddressFamily.InterNetworkV6 && address.IsIPv6LinkLocal) + continue; + if (address.AddressFamily is AddressFamily.InterNetwork or AddressFamily.InterNetworkV6) + result.Add(new LocalAddress(nic.Name, nic.NetworkInterfaceType, address)); + } + } + return result.OrderBy(a => a.Address.AddressFamily == AddressFamily.InterNetwork ? 0 : 1).ToList(); + } + + /// URL de un share en una direccion: http://192.168.1.10:8080/fotos/ + public static string HttpUrl(IPAddress address, int port, ShareConfig? share = null) + { + string host = address.AddressFamily == AddressFamily.InterNetworkV6 ? $"[{address}]" : address.ToString(); + string path = "/"; + if (share is not null && share.Location != "/") + path = "/" + string.Join('/', share.Location.Split('/', StringSplitOptions.RemoveEmptyEntries).Select(HtmlBuilder.UrlSegment)) + "/"; + return port == 80 ? $"http://{host}{path}" : $"http://{host}:{port}{path}"; + } + + /// URL FTP de un share: ftp://192.168.1.10:2121/fotos/ + public static string FtpUrl(IPAddress address, int port, ShareConfig? share = null) + { + string http = HttpUrl(address, 80, share); + string rest = http["http://".Length..]; + int slash = rest.IndexOf('/'); + string host = rest[..slash]; + return port == 21 ? $"ftp://{host}{rest[slash..]}" : $"ftp://{host}:{port}{rest[slash..]}"; + } +} diff --git a/src/Sunfish.Core/Qr/QrCode.cs b/src/Sunfish.Core/Qr/QrCode.cs new file mode 100644 index 0000000..2e1ec0a --- /dev/null +++ b/src/Sunfish.Core/Qr/QrCode.cs @@ -0,0 +1,429 @@ +using System.Text; + +namespace Sunfish.Qr; + +/// Nivel de correccion de errores: L 7 %, M 15 %, Q 25 %, H 30 %. +public enum QrEcc { Low, Medium, Quartile, High } + +/// +/// Generador de codigos QR propio (ISO/IEC 18004), sin dependencias: modo byte (UTF-8), versiones +/// 1 a 40 y eleccion de mascara por penalizacion. Devuelve la matriz de modulos; la pintan la GUI +/// (Bitmap) y la TUI (medios bloques). Pensado para URL cortas, asi que se usa el nivel de +/// correccion mas alto que quepa en la version minima. +/// +public sealed class QrCode +{ + public int Version { get; } + public int Size { get; } + public QrEcc Ecc { get; } + public int Mask { get; } + + private readonly bool[,] _modules; + private readonly bool[,] _function; + + /// Modulo oscuro en la columna x, fila y (0..Size-1). + public bool this[int x, int y] => _modules[y, x]; + + public static QrCode Encode(string text, QrEcc minimum = QrEcc.Medium) => + EncodeBytes(Encoding.UTF8.GetBytes(text), minimum); + + public static QrCode EncodeBytes(byte[] data, QrEcc minimum = QrEcc.Medium) + { + int version = 1; + int usedBits; + while (true) + { + usedBits = 4 + (version <= 9 ? 8 : 16) + data.Length * 8; + if (usedBits <= DataCodewords(version, minimum) * 8) + break; + if (++version > 40) + throw new ArgumentException("Data too long for a QR code"); + } + var ecc = minimum; + for (var e = minimum + 1; e <= QrEcc.High; e++) + if (usedBits <= DataCodewords(version, e) * 8) + ecc = e; + + var bits = new List(); + void Append(int value, int count) + { + for (int i = count - 1; i >= 0; i--) + bits.Add(((value >> i) & 1) != 0); + } + Append(0b0100, 4); // modo byte + Append(data.Length, version <= 9 ? 8 : 16); + foreach (byte b in data) + Append(b, 8); + int capacity = DataCodewords(version, ecc) * 8; + Append(0, Math.Min(4, capacity - bits.Count)); + Append(0, (8 - bits.Count % 8) % 8); + for (byte pad = 0xEC; bits.Count < capacity; pad ^= 0xEC ^ 0x11) + Append(pad, 8); + + var codewords = new byte[bits.Count / 8]; + for (int i = 0; i < bits.Count; i++) + if (bits[i]) + codewords[i >> 3] |= (byte)(0x80 >> (i & 7)); + return new QrCode(version, ecc, codewords); + } + + private QrCode(int version, QrEcc ecc, byte[] dataCodewords) + { + Version = version; + Ecc = ecc; + Size = version * 4 + 17; + _modules = new bool[Size, Size]; + _function = new bool[Size, Size]; + + DrawFunctionPatterns(); + DrawCodewords(AddEccAndInterleave(dataCodewords)); + + int best = 0; + long bestPenalty = long.MaxValue; + for (int mask = 0; mask < 8; mask++) + { + ApplyMask(mask); + DrawFormatBits(mask); + long penalty = Penalty(); + if (penalty < bestPenalty) + { + best = mask; + bestPenalty = penalty; + } + ApplyMask(mask); // XOR: deshace + } + Mask = best; + ApplyMask(best); + DrawFormatBits(best); + } + + // ------------------------------------------------------------------ patrones fijos + + private void DrawFunctionPatterns() + { + for (int i = 0; i < Size; i++) + { + SetFunction(6, i, i % 2 == 0); + SetFunction(i, 6, i % 2 == 0); + } + DrawFinder(3, 3); + DrawFinder(Size - 4, 3); + DrawFinder(3, Size - 4); + + var positions = AlignmentPositions(); + int n = positions.Length; + for (int i = 0; i < n; i++) + for (int j = 0; j < n; j++) + if (!((i == 0 && j == 0) || (i == 0 && j == n - 1) || (i == n - 1 && j == 0))) + DrawAlignment(positions[i], positions[j]); + + DrawFormatBits(0); // reserva el sitio; se reescribe con la mascara elegida + DrawVersion(); + } + + private void DrawFinder(int x, int y) + { + for (int dy = -4; dy <= 4; dy++) + for (int dx = -4; dx <= 4; dx++) + { + int dist = Math.Max(Math.Abs(dx), Math.Abs(dy)); + int xx = x + dx, yy = y + dy; + if (xx >= 0 && xx < Size && yy >= 0 && yy < Size) + SetFunction(xx, yy, dist != 2 && dist != 4); + } + } + + private void DrawAlignment(int x, int y) + { + for (int dy = -2; dy <= 2; dy++) + for (int dx = -2; dx <= 2; dx++) + SetFunction(x + dx, y + dy, Math.Max(Math.Abs(dx), Math.Abs(dy)) != 1); + } + + private int[] AlignmentPositions() + { + if (Version == 1) + return []; + int count = Version / 7 + 2; + int step = Version == 32 ? 26 : (Version * 4 + count * 2 + 1) / (count * 2 - 2) * 2; + var result = new int[count]; + result[0] = 6; + for (int i = count - 1, pos = Size - 7; i >= 1; i--, pos -= step) + result[i] = pos; + return result; + } + + private void DrawFormatBits(int mask) + { + int eccBits = Ecc switch { QrEcc.Low => 1, QrEcc.Medium => 0, QrEcc.Quartile => 3, _ => 2 }; + int data = eccBits << 3 | mask; + int rem = data; + for (int i = 0; i < 10; i++) + rem = (rem << 1) ^ ((rem >> 9) * 0x537); + int bits = (data << 10 | rem) ^ 0x5412; + + for (int i = 0; i <= 5; i++) + SetFunction(8, i, Bit(bits, i)); + SetFunction(8, 7, Bit(bits, 6)); + SetFunction(8, 8, Bit(bits, 7)); + SetFunction(7, 8, Bit(bits, 8)); + for (int i = 9; i < 15; i++) + SetFunction(14 - i, 8, Bit(bits, i)); + + for (int i = 0; i < 8; i++) + SetFunction(Size - 1 - i, 8, Bit(bits, i)); + for (int i = 8; i < 15; i++) + SetFunction(8, Size - 15 + i, Bit(bits, i)); + SetFunction(8, Size - 8, true); // modulo oscuro fijo + } + + private void DrawVersion() + { + if (Version < 7) + return; + int rem = Version; + for (int i = 0; i < 12; i++) + rem = (rem << 1) ^ ((rem >> 11) * 0x1F25); + int bits = Version << 12 | rem; + for (int i = 0; i < 18; i++) + { + bool bit = Bit(bits, i); + int a = Size - 11 + i % 3, b = i / 3; + SetFunction(a, b, bit); + SetFunction(b, a, bit); + } + } + + private void SetFunction(int x, int y, bool dark) + { + _modules[y, x] = dark; + _function[y, x] = true; + } + + private static bool Bit(int value, int index) => ((value >> index) & 1) != 0; + + // ------------------------------------------------------------------ datos + + private byte[] AddEccAndInterleave(byte[] data) + { + int blocks = Blocks[(int)Ecc][Version]; + int eccLength = EccPerBlock[(int)Ecc][Version]; + int raw = RawDataModules(Version) / 8; + int shortBlocks = blocks - raw % blocks; + int shortLength = raw / blocks; + var divisor = ReedSolomon.Divisor(eccLength); + + var all = new List(); + for (int i = 0, k = 0; i < blocks; i++) + { + int dataLength = shortLength - eccLength + (i < shortBlocks ? 0 : 1); + var dat = data.AsSpan(k, dataLength).ToArray(); + k += dataLength; + var ecc = ReedSolomon.Remainder(dat, divisor); + // Los bloques cortos llevan un hueco para que todos midan lo mismo al intercalar. + var block = new byte[shortLength + 1]; + dat.CopyTo(block, 0); + ecc.CopyTo(block, shortLength + 1 - eccLength); + all.Add(block); + } + + var result = new List(raw); + for (int i = 0; i < shortLength + 1; i++) + for (int j = 0; j < all.Count; j++) + if (i != shortLength - eccLength || j >= shortBlocks) + result.Add(all[j][i]); + return [.. result]; + } + + private void DrawCodewords(byte[] data) + { + int i = 0; + for (int right = Size - 1; right >= 1; right -= 2) + { + if (right == 6) + right = 5; + for (int vert = 0; vert < Size; vert++) + for (int j = 0; j < 2; j++) + { + int x = right - j; + bool upward = ((right + 1) & 2) == 0; + int y = upward ? Size - 1 - vert : vert; + if (!_function[y, x] && i < data.Length * 8) + { + _modules[y, x] = ((data[i >> 3] >> (7 - (i & 7))) & 1) != 0; + i++; + } + } + } + } + + private void ApplyMask(int mask) + { + for (int y = 0; y < Size; y++) + for (int x = 0; x < Size; x++) + { + if (_function[y, x]) + continue; + bool invert = mask switch + { + 0 => (x + y) % 2 == 0, + 1 => y % 2 == 0, + 2 => x % 3 == 0, + 3 => (x + y) % 3 == 0, + 4 => (x / 3 + y / 2) % 2 == 0, + 5 => x * y % 2 + x * y % 3 == 0, + 6 => (x * y % 2 + x * y % 3) % 2 == 0, + _ => ((x + y) % 2 + x * y % 3) % 2 == 0, + }; + _modules[y, x] ^= invert; + } + } + + /// Penalizacion de la norma (rachas, bloques 2x2, patrones de buscador, equilibrio). + private long Penalty() + { + long result = 0; + for (int pass = 0; pass < 2; pass++) + for (int a = 0; a < Size; a++) + { + int run = 1; + for (int b = 1; b < Size; b++) + { + bool same = Get(pass, a, b) == Get(pass, a, b - 1); + if (same) + run++; + if (!same || b == Size - 1) + { + if (run >= 5) + result += 3 + run - 5; + run = 1; + } + } + for (int b = 0; b + 10 < Size; b++) + if (FinderLike(pass, a, b)) + result += 40; + } + + for (int y = 0; y < Size - 1; y++) + for (int x = 0; x < Size - 1; x++) + { + bool c = _modules[y, x]; + if (c == _modules[y, x + 1] && c == _modules[y + 1, x] && c == _modules[y + 1, x + 1]) + result += 3; + } + + int dark = 0; + foreach (bool m in _modules) + if (m) + dark++; + int total = Size * Size; + int k = (Math.Abs(dark * 20 - total * 10) + total - 1) / total - 1; + return result + Math.Max(0, k) * 10; + } + + private bool Get(int pass, int a, int b) => pass == 0 ? _modules[a, b] : _modules[b, a]; + + private static readonly bool[] FinderCore = [true, false, true, true, true, false, true]; + + /// 1011101 con cuatro claros delante o detras. + private bool FinderLike(int pass, int a, int b) + { + bool[] core = FinderCore; + bool Matches(int start) + { + for (int i = 0; i < 7; i++) + if (Get(pass, a, start + i) != core[i]) + return false; + return true; + } + bool Light(int start) + { + for (int i = 0; i < 4; i++) + if (Get(pass, a, start + i)) + return false; + return true; + } + return (Matches(b) && Light(b + 7)) || (Matches(b + 4) && Light(b)); + } + + // ------------------------------------------------------------------ tablas + + private static int RawDataModules(int version) + { + int result = (16 * version + 128) * version + 64; + if (version >= 2) + { + int align = version / 7 + 2; + result -= (25 * align - 10) * align - 55; + if (version >= 7) + result -= 36; + } + return result; + } + + internal static int DataCodewords(int version, QrEcc ecc) => + RawDataModules(version) / 8 - EccPerBlock[(int)ecc][version] * Blocks[(int)ecc][version]; + + private static readonly int[][] EccPerBlock = + [ + [-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], + [-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28], + [-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], + [-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], + ]; + + private static readonly int[][] Blocks = + [ + [-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25], + [-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49], + [-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68], + [-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81], + ]; + + /// Reed-Solomon sobre GF(256) con el polinomio 0x11D. + private static class ReedSolomon + { + public static byte[] Divisor(int degree) + { + var result = new byte[degree]; + result[degree - 1] = 1; + int root = 1; + for (int i = 0; i < degree; i++) + { + for (int j = 0; j < degree; j++) + { + result[j] = Multiply(result[j], root); + if (j + 1 < degree) + result[j] ^= result[j + 1]; + } + root = Multiply(root, 0x02); + } + return result; + } + + public static byte[] Remainder(byte[] data, byte[] divisor) + { + var result = new byte[divisor.Length]; + foreach (byte b in data) + { + int factor = b ^ result[0]; + Array.Copy(result, 1, result, 0, result.Length - 1); + result[^1] = 0; + for (int i = 0; i < result.Length; i++) + result[i] ^= Multiply(divisor[i], factor); + } + return result; + } + + private static byte Multiply(int x, int y) + { + int z = 0; + for (int i = 7; i >= 0; i--) + { + z = (z << 1) ^ ((z >> 7) * 0x11D); + z ^= ((y >> i) & 1) * x; + } + return (byte)z; + } + } +} diff --git a/src/Sunfish.Core/Qr/QrImage.cs b/src/Sunfish.Core/Qr/QrImage.cs new file mode 100644 index 0000000..5343c9a --- /dev/null +++ b/src/Sunfish.Core/Qr/QrImage.cs @@ -0,0 +1,91 @@ +using System.Buffers.Binary; +using System.IO.Compression; +using System.Text; + +namespace Sunfish.Qr; + +/// Salidas de un : PNG en blanco y negro y texto con medios bloques. +public static class QrImage +{ + /// Margen claro alrededor que exige la norma (en modulos). + public const int QuietZone = 4; + + /// PNG de 1 bit por pixel, pixeles por modulo, con margen. + public static byte[] Png(QrCode qr, int scale = 6, int quiet = QuietZone) + { + int size = (qr.Size + quiet * 2) * scale; + int rowBytes = (size + 7) / 8; + var raw = new byte[(rowBytes + 1) * size]; + for (int y = 0; y < size; y++) + { + int row = y * (rowBytes + 1); // el primer byte de cada fila es el filtro (0, ninguno) + for (int x = 0; x < size; x++) + { + int mx = x / scale - quiet, my = y / scale - quiet; + bool dark = mx >= 0 && my >= 0 && mx < qr.Size && my < qr.Size && qr[mx, my]; + if (!dark) // 1 = blanco en escala de grises de 1 bit + raw[row + 1 + x / 8] |= (byte)(0x80 >> (x % 8)); + } + } + + var png = new MemoryStream(); + png.Write([0x89, (byte)'P', (byte)'N', (byte)'G', 0x0D, 0x0A, 0x1A, 0x0A]); + var header = new byte[13]; + BinaryPrimitives.WriteUInt32BigEndian(header, (uint)size); + BinaryPrimitives.WriteUInt32BigEndian(header.AsSpan(4), (uint)size); + header[8] = 1; // bits por pixel + header[9] = 0; // escala de grises + Chunk(png, "IHDR", header); + var compressed = new MemoryStream(); + using (var z = new ZLibStream(compressed, CompressionLevel.Optimal, leaveOpen: true)) + z.Write(raw); + Chunk(png, "IDAT", compressed.ToArray()); + Chunk(png, "IEND", []); + return png.ToArray(); + } + + private static void Chunk(Stream output, string type, byte[] data) + { + Span length = stackalloc byte[4]; + BinaryPrimitives.WriteUInt32BigEndian(length, (uint)data.Length); + output.Write(length); + var typeBytes = Encoding.ASCII.GetBytes(type); + output.Write(typeBytes); + output.Write(data); + uint crc = Web.Crc32.Update(Web.Crc32.Update(0, typeBytes), data); + Span crcBytes = stackalloc byte[4]; + BinaryPrimitives.WriteUInt32BigEndian(crcBytes, crc); + output.Write(crcBytes); + } + + /// + /// Filas de texto con medios bloques (dos modulos por caracter en vertical). true = oscuro; + /// quien lo pinta decide colores (la TUI pone fondo claro para que lo lean los moviles). + /// + public static IEnumerable HalfBlocks(QrCode qr, int quiet = 2) + { + int size = qr.Size + quiet * 2; + bool Dark(int x, int y) + { + x -= quiet; + y -= quiet; + return x >= 0 && y >= 0 && x < qr.Size && y < qr.Size && qr[x, y]; + } + for (int y = 0; y < size; y += 2) + { + var sb = new StringBuilder(size); + for (int x = 0; x < size; x++) + { + bool top = Dark(x, y), bottom = y + 1 < size && Dark(x, y + 1); + sb.Append((top, bottom) switch + { + (true, true) => '█', + (true, false) => '▀', + (false, true) => '▄', + _ => ' ', + }); + } + yield return sb.ToString(); + } + } +} diff --git a/src/Sunfish.Core/Shares/FileOperations.cs b/src/Sunfish.Core/Shares/FileOperations.cs new file mode 100644 index 0000000..9abf527 --- /dev/null +++ b/src/Sunfish.Core/Shares/FileOperations.cs @@ -0,0 +1,280 @@ +namespace Sunfish.Shares; + +public enum FileOpResult { Ok, InvalidName, NotFound, Exists, NoSpace, Conflict, Failed } + +/// +/// Cambios en disco comunes a web, FTP y WebDAV. Los nombres ya llegan validados por la capa de +/// protocolo, pero se vuelven a comprobar aqui. Las subidas se escriben en un temporal oculto de +/// la misma carpeta y se renombran al terminar: una subida cortada no deja un fichero a medias +/// ni pisa el existente. +/// +public static class FileOperations +{ + public const string TempPrefix = ".sunfish-"; + + /// Nombre aceptable para crear: valido en el sistema y que no empiece por '.'. + public static bool IsCreatableName(string name) => + PathRules.IsValidSegment(name) && !name.StartsWith('.') && name.Length <= 255; + + /// + /// Metadatos que macOS escribe en volumenes sin forks (._nombre = AppleDouble, .DS_Store). + /// Por WebDAV se aceptan aunque empiecen por '.'; no salen en los listados. + /// + public static bool IsMacMetadataName(string name) => + PathRules.IsValidSegment(name) && name.Length <= 255 && + ((name.StartsWith("._", StringComparison.Ordinal) && name.Length > 2) || name == ".DS_Store") && + !name.StartsWith(TempPrefix, StringComparison.Ordinal); + + /// + /// Nombre de un fichero subido: sin la ruta que envian algunos navegadores (IE antiguo manda + /// "C:\WINDOWS\a.txt"; Mac clasico puede usar ':') y sin espacios sobrantes. + /// + public static string CleanUploadName(string fileName) + { + int cut = fileName.LastIndexOfAny(['\\', '/', ':']); + return (cut >= 0 ? fileName[(cut + 1)..] : fileName).Trim(); + } + + public static FileOpResult CreateFolder(string directory, string name) + { + if (!IsCreatableName(name)) + return FileOpResult.InvalidName; + string target = Path.Combine(directory, name); + if (Directory.Exists(target) || File.Exists(target)) + return FileOpResult.Exists; + try + { + Directory.CreateDirectory(target); + return FileOpResult.Ok; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + return FileOpResult.Failed; + } + } + + /// Borra un fichero o una carpeta con todo su contenido (los enlaces se borran, no se siguen). + public static FileOpResult Delete(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + DeleteCompanion(path); + } + else if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + else + return FileOpResult.NotFound; + return FileOpResult.Ok; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + return FileOpResult.Failed; + } + } + + /// Cambia el nombre dentro de la misma carpeta; no sustituye nada existente. + public static FileOpResult Rename(string path, string newName) + { + if (!IsCreatableName(newName)) + return FileOpResult.InvalidName; + string? directory = Path.GetDirectoryName(path); + if (directory is null) + return FileOpResult.Failed; + string target = Path.Combine(directory, newName); + bool sameItem = string.Equals(target, path, StringComparison.OrdinalIgnoreCase); + if (!sameItem && (File.Exists(target) || Directory.Exists(target))) + return FileOpResult.Exists; + try + { + if (File.Exists(path)) + { + File.Move(path, target); + MoveCompanion(path, target); + } + else if (Directory.Exists(path)) + Directory.Move(path, target); // tambien vale para cambiar solo mayusculas en Windows + else + return FileOpResult.NotFound; + return FileOpResult.Ok; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + return FileOpResult.Failed; + } + } + + /// + /// Mueve (o renombra) a otra ruta ya validada del mismo share; no sustituye nada existente. + /// No mueve una carpeta dentro de si misma. + /// + public static FileOpResult Move(string source, string target) + { + string? name = Path.GetFileName(target); + if (name is null || !IsCreatableName(name)) + return FileOpResult.InvalidName; + bool sameItem = string.Equals(source, target, StringComparison.OrdinalIgnoreCase); + if (!sameItem && (File.Exists(target) || Directory.Exists(target))) + return FileOpResult.Exists; + string? parent = Path.GetDirectoryName(target); + if (parent is null || !Directory.Exists(parent)) + return FileOpResult.NotFound; + try + { + if (File.Exists(source)) + { + File.Move(source, target); + MoveCompanion(source, target); + } + else if (Directory.Exists(source)) + { + if (target.StartsWith(Path.TrimEndingDirectorySeparator(source) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + return FileOpResult.Conflict; + Directory.Move(source, target); + } + else + return FileOpResult.NotFound; + return FileOpResult.Ok; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + return FileOpResult.Failed; + } + } + + /// Borra una carpeta solo si esta vacia (RMD de FTP). + public static FileOpResult DeleteEmptyFolder(string path) + { + if (!Directory.Exists(path)) + return FileOpResult.NotFound; + if (Directory.EnumerateFileSystemEntries(path).Any()) + return FileOpResult.Conflict; + try + { + Directory.Delete(path); + return FileOpResult.Ok; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + return FileOpResult.Failed; + } + } + + /// Hay sitio para en la unidad de la carpeta (si se puede saber). + public static bool HasSpace(string directory, long bytes) + { + try + { + string? root = Path.GetPathRoot(Path.GetFullPath(directory)); + return root is null || new DriveInfo(root).AvailableFreeSpace >= bytes; + } + catch (Exception e) when (e is IOException or ArgumentException or UnauthorizedAccessException) + { + return true; + } + } + + /// Guarda un flujo como fichero nuevo (o sustituye el existente si se permite). + public static async Task SaveAsync(string directory, string name, Stream content, bool overwrite, CancellationToken ct, bool allowMacMetadata = false) + { + if (!IsCreatableName(name) && !(allowMacMetadata && IsMacMetadataName(name))) + return FileOpResult.InvalidName; + string target = Path.Combine(directory, name); + if (Directory.Exists(target)) + return FileOpResult.Exists; + if (File.Exists(target) && !overwrite) + return FileOpResult.Exists; + string temp = Path.Combine(directory, TempPrefix + Guid.NewGuid().ToString("N") + ".tmp"); + try + { + await using (var file = new FileStream(temp, FileMode.CreateNew, FileAccess.Write, FileShare.None, 65536, useAsync: true)) + await content.CopyToAsync(file, 65536, ct); + return Commit(temp, target, overwrite); + } + catch + { + TryDelete(temp); + throw; + } + } + + /// + /// Escribe un trozo de una subida por partes (PUT con Content-Range). El temporal se identifica + /// por el id que manda el cliente; cada trozo debe empezar donde acabo el anterior. Con el + /// ultimo trozo el fichero se renombra a su sitio. + /// + public static async Task<(FileOpResult Result, bool Completed)> WritePartAsync( + string directory, string name, string uploadId, long start, long total, Stream content, bool overwrite, CancellationToken ct) + { + if (!IsCreatableName(name)) + return (FileOpResult.InvalidName, false); + string target = Path.Combine(directory, name); + if (Directory.Exists(target) || (File.Exists(target) && !overwrite)) + return (FileOpResult.Exists, false); + string temp = Path.Combine(directory, TempPrefix + uploadId + ".part"); + long length; + await using (var file = new FileStream(temp, start == 0 ? FileMode.Create : FileMode.Open, FileAccess.Write, FileShare.None, 65536, useAsync: true)) + { + if (file.Length != start) + return (FileOpResult.Conflict, false); + file.Position = start; + await content.CopyToAsync(file, 65536, ct); + length = file.Length; + } + if (length > total) + { + TryDelete(temp); + return (FileOpResult.Conflict, false); + } + if (length < total) + return (FileOpResult.Ok, false); + return (Commit(temp, target, overwrite), true); + } + + private static FileOpResult Commit(string temp, string target, bool overwrite) + { + try + { + File.Move(temp, target, overwrite); + return FileOpResult.Ok; + } + catch (IOException) when (File.Exists(target) && !overwrite) + { + TryDelete(temp); + return FileOpResult.Exists; + } + } + + /// + /// El AppleDouble ("._nombre") de un fichero de Mac acompana siempre a su fichero: se mueve y + /// se borra con el. + /// + private static string CompanionOf(string path) => + Path.Combine(Path.GetDirectoryName(path) ?? "", "._" + Path.GetFileName(path)); + + private static void MoveCompanion(string source, string target) + { + if (Path.GetFileName(source).StartsWith("._", StringComparison.Ordinal)) + return; + string from = CompanionOf(source); + if (File.Exists(from)) + { + try { File.Move(from, CompanionOf(target), overwrite: true); } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) { } + } + } + + private static void DeleteCompanion(string path) + { + if (!Path.GetFileName(path).StartsWith("._", StringComparison.Ordinal)) + TryDelete(CompanionOf(path)); + } + + private static void TryDelete(string path) + { + try { File.Delete(path); } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) { } + } +} diff --git a/src/Sunfish.Core/Shares/PathRules.cs b/src/Sunfish.Core/Shares/PathRules.cs new file mode 100644 index 0000000..e9e195f --- /dev/null +++ b/src/Sunfish.Core/Shares/PathRules.cs @@ -0,0 +1,132 @@ +using System.Text; + +namespace Sunfish.Shares; + +/// +/// Validacion de los nombres que llegan por la red (web, FTP, WebDAV) antes de tocar el disco. +/// Es la misma para todos los protocolos. +/// +public static class PathRules +{ + private static readonly HashSet WindowsReserved = new(StringComparer.OrdinalIgnoreCase) + { + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", + }; + + /// Un componente de ruta valido en cualquier sistema (y ademas en Windows si se pide). + public static bool IsValidSegment(string segment, bool windows) + { + if (segment.Length == 0 || segment == "." || segment == "..") + return false; + foreach (char c in segment) + { + if (c < 0x20 || c == 0x7F || c == '/' || c == '\\' || c == ':') + return false; + } + if (windows) + { + if (segment.EndsWith('.') || segment.EndsWith(' ')) + return false; + if (segment.IndexOfAny(['<', '>', '"', '|', '?', '*']) >= 0) + return false; + int dot = segment.IndexOf('.'); + string stem = dot < 0 ? segment : segment[..dot]; + if (WindowsReserved.Contains(stem.TrimEnd(' '))) + return false; + } + return true; + } + + public static bool IsValidSegment(string segment) => IsValidSegment(segment, OperatingSystem.IsWindows()); + + /// + /// Parte una ruta de URL ("/a/b%20c/") en componentes decodificados. Los componentes vacios + /// ("//") se ignoran. Devuelve false si algun componente no es valido. + /// + public static bool TrySplitUrlPath(string path, out List segments, out bool trailingSlash) + { + segments = new List(); + trailingSlash = path.EndsWith('/'); + foreach (var raw in path.Split('/')) + { + if (raw.Length == 0) + continue; + string segment = PercentDecode(raw); + if (!IsValidSegment(segment)) + return false; + segments.Add(segment); + } + return true; + } + + /// + /// Decodifica %XX como UTF-8. Si los bytes no son UTF-8 valido (navegadores antiguos que envian + /// el juego de caracteres de la pagina) se interpretan como Latin-1. + /// + public static string PercentDecode(string text) + { + if (text.IndexOf('%') < 0) + return text; + var bytes = new List(text.Length); + for (int i = 0; i < text.Length; i++) + { + char c = text[i]; + if (c == '%' && i + 2 < text.Length && IsHex(text[i + 1]) && IsHex(text[i + 2])) + { + bytes.Add((byte)(HexValue(text[i + 1]) * 16 + HexValue(text[i + 2]))); + i += 2; + } + else if (c < 0x80) + { + bytes.Add((byte)c); + } + else + { + bytes.AddRange(Encoding.UTF8.GetBytes(c.ToString())); + } + } + var array = bytes.ToArray(); + try + { + return new UTF8Encoding(false, throwOnInvalidBytes: true).GetString(array); + } + catch (DecoderFallbackException) + { + return Encoding.Latin1.GetString(array); + } + } + + private static bool IsHex(char c) => char.IsAsciiHexDigit(c); + + private static int HexValue(char c) => c <= '9' ? c - '0' : (c | 0x20) - 'a' + 10; +} + +/// Locations de los shares: "/", "/fotos", "/fotos/viaje". +public static class LocationPath +{ + public const string Reserved = "$sunfish"; + + /// Normaliza a "/a/b" (sin barra final salvo la raiz). Rechaza componentes invalidos y /$sunfish. + public static bool TryNormalize(string location, out string normalized) + { + normalized = "/"; + var parts = new List(); + foreach (var part in location.Trim().Split('/', '\\')) + { + if (part.Length == 0) + continue; + if (!PathRules.IsValidSegment(part, windows: false) || part.Contains('%')) + return false; + parts.Add(part); + } + if (parts.Count > 0 && parts[0].Equals(Reserved, StringComparison.OrdinalIgnoreCase)) + return false; + normalized = "/" + string.Join('/', parts); + return true; + } + + public static string[] Segments(string normalized) => + normalized.Split('/', StringSplitOptions.RemoveEmptyEntries); +} diff --git a/src/Sunfish.Core/Shares/ShareFileSystem.cs b/src/Sunfish.Core/Shares/ShareFileSystem.cs new file mode 100644 index 0000000..25e7374 --- /dev/null +++ b/src/Sunfish.Core/Shares/ShareFileSystem.cs @@ -0,0 +1,81 @@ +using Sunfish.Config; + +namespace Sunfish.Shares; + +/// Una entrada de un listado (real o virtual). +public sealed record FileEntry(string Name, bool IsDirectory, long Size, DateTime Modified, bool IsVirtual = false, bool IsLink = false); + +/// +/// Acceso al disco de un share. Toda ruta pasa por , que garantiza +/// que no se sale de la carpeta del share (ni con "..", ni con rutas absolutas, ni con enlaces). +/// +public static class ShareFileSystem +{ + private static readonly StringComparison PathComparison = + OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + public static string RootOf(ShareConfig share) => + Path.TrimEndingDirectorySeparator(Path.GetFullPath(share.Path)); + + /// Ruta real dentro del share, o null si los componentes no son validos o escaparian de el. + public static string? TryGetPhysicalPath(ShareConfig share, IReadOnlyList relative, bool aliases = false) + { + string root = RootOf(share); + string current = root; + foreach (var segment in relative) + { + if (!PathRules.IsValidSegment(segment)) + return null; + string next = Path.Combine(current, segment); + // Alias 8.3 (DOS, Windows 3.1): solo si el nombre tal cual no existe. + if (aliases && !File.Exists(next) && !Directory.Exists(next) && + ShortNames.Resolve(current, segment) is { } real) + next = Path.Combine(current, real); + current = next; + if (!IsInside(root, Path.GetFullPath(current))) + return null; + // Un enlace (simbolico o junction) solo se sigue si su destino final sigue dentro. + FileSystemInfo info = Directory.Exists(current) ? new DirectoryInfo(current) : new FileInfo(current); + if (info.Exists && info.LinkTarget is not null) + { + var target = info.ResolveLinkTarget(returnFinalTarget: true); + if (target is null || !IsInside(root, Path.GetFullPath(target.FullName))) + return null; + } + } + return current; + } + + public static bool IsInside(string root, string fullPath) + { + if (string.Equals(root, fullPath, PathComparison)) + return true; + string prefix = root.EndsWith(Path.DirectorySeparatorChar) ? root : root + Path.DirectorySeparatorChar; + return fullPath.StartsWith(prefix, PathComparison); + } + + /// + /// Contenido de una carpeta para listar. Se omiten los ocultos (atributo Hidden/System en + /// Windows, nombres que empiezan por '.') aunque se puedan pedir directamente por su URL. + /// + public static List List(string physicalDirectory) + { + var entries = new List(); + var dir = new DirectoryInfo(physicalDirectory); + foreach (var info in dir.EnumerateFileSystemInfos()) + { + if (IsHidden(info)) + continue; + bool link = info.LinkTarget is not null; + if (info is DirectoryInfo) + entries.Add(new FileEntry(info.Name, true, 0, info.LastWriteTime, IsLink: link)); + else if (info is FileInfo file) + entries.Add(new FileEntry(file.Name, false, file.Length, file.LastWriteTime, IsLink: link)); + } + return entries; + } + + private static bool IsHidden(FileSystemInfo info) => + info.Name.StartsWith('.') || info.Name == "Icon\r" || // icono de carpeta de Mac + (info.Attributes & (FileAttributes.Hidden | FileAttributes.System)) != 0; +} diff --git a/src/Sunfish.Core/Shares/ShareTree.cs b/src/Sunfish.Core/Shares/ShareTree.cs new file mode 100644 index 0000000..60aa2bb --- /dev/null +++ b/src/Sunfish.Core/Shares/ShareTree.cs @@ -0,0 +1,92 @@ +using Sunfish.Config; + +namespace Sunfish.Shares; + +/// Resultado de resolver una ruta contra los shares. +public sealed class Resolution +{ + /// Share que atiende la ruta (location de prefijo mas largo); null si ninguno. + public ShareConfig? Share { get; init; } + + /// Componentes de la ruta dentro del share. + public IReadOnlyList Relative { get; init; } = []; + + /// + /// Locations mas especificas que cuelgan de esta ruta, como carpetas virtuales + /// (nombre del siguiente componente). Tapan a las carpetas reales del mismo nombre. + /// + public IReadOnlyList VirtualChildren { get; init; } = []; +} + +/// +/// Arbol de locations. Gana el prefijo mas largo, comparando por componentes completos y sin +/// distinguir mayusculas (como las locations de prefijo de nginx). +/// +public sealed class ShareTree +{ + private readonly List<(ShareConfig Share, string[] Segments)> _entries; + + public ShareTree(IEnumerable shares) + { + _entries = shares + .Where(s => s.Enabled) + .Select(s => (s, LocationPath.Segments(s.Location))) + .OrderByDescending(e => e.Item2.Length) + .ToList(); + } + + public IEnumerable Shares => _entries.Select(e => e.Share); + + public Resolution Resolve(IReadOnlyList segments) + { + ShareConfig? share = null; + int matched = 0; + foreach (var (candidate, location) in _entries) + { + if (IsPrefix(location, segments)) + { + share = candidate; + matched = location.Length; + break; // ordenadas de mas a menos componentes + } + } + return new Resolution + { + Share = share, + Relative = segments.Skip(matched).ToArray(), + VirtualChildren = ChildrenOf(segments), + }; + } + + /// Nombres de las locations que cuelgan directamente o indirectamente de la ruta. + public IReadOnlyList ChildrenOf(IReadOnlyList segments) + { + var names = new List(); + foreach (var (_, location) in _entries) + { + if (location.Length > segments.Count && IsPrefix(segments, location)) + { + string child = location[segments.Count]; + if (!names.Contains(child, StringComparer.OrdinalIgnoreCase)) + names.Add(child); + } + } + names.Sort(StringComparer.OrdinalIgnoreCase); + return names; + } + + /// La location del share, para construir URLs. + public static string UrlOf(ShareConfig share) => share.Location == "/" ? "/" : share.Location + "/"; + + private static bool IsPrefix(IReadOnlyList prefix, IReadOnlyList path) + { + if (prefix.Count > path.Count) + return false; + for (int i = 0; i < prefix.Count; i++) + { + if (!string.Equals(prefix[i], path[i], StringComparison.OrdinalIgnoreCase)) + return false; + } + return true; + } +} diff --git a/src/Sunfish.Core/Shares/ShortNames.cs b/src/Sunfish.Core/Shares/ShortNames.cs new file mode 100644 index 0000000..e08e55d --- /dev/null +++ b/src/Sunfish.Core/Shares/ShortNames.cs @@ -0,0 +1,112 @@ +using System.Globalization; +using System.Text; + +namespace Sunfish.Shares; + +/// +/// Alias 8.3 para DOS y Windows 3.1 (DOC/CLIENTES.md). Son estables: dependen del nombre (un hash), +/// no del orden de la carpeta, asi que un listado o un enlace antiguo no acaban apuntando a otro +/// fichero cuando se anaden o quitan otros. "Fotos del viaje.jpeg" -> "FOTxxx~1.JPE" (xxx: el hash). +/// +public static class ShortNames +{ + private const string Allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$%&'()-@^_`{}~"; + + /// El nombre ya es 8.3 valido (sin distinguir mayusculas). + public static bool Is83(string name) + { + int dot = name.IndexOf('.'); + string stem = dot < 0 ? name : name[..dot]; + string ext = dot < 0 ? "" : name[(dot + 1)..]; + return stem.Length is >= 1 and <= 8 && ext.Length <= 3 && ext.IndexOf('.') < 0 && + (dot < 0 || ext.Length > 0) && + stem.ToUpperInvariant().All(c => Allowed.Contains(c)) && ext.ToUpperInvariant().All(c => Allowed.Contains(c)); + } + + /// Alias de un nombre suelto (sin tener en cuenta colisiones). + public static string Alias(string name, int number = 1) + { + if (number == 1 && Is83(name)) + return name; + int dot = name.LastIndexOf('.'); + string stem = dot > 0 ? name[..dot] : name; + string ext = dot > 0 ? Clean(name[(dot + 1)..], 3) : ""; + string prefix = Clean(stem, 3); + if (prefix.Length == 0) + prefix = "_"; + string hash = (Fnv1a(name) % 0x1000).ToString("X3", CultureInfo.InvariantCulture); + string tail = "~" + number.ToString(CultureInfo.InvariantCulture); + string baseName = (prefix + hash)[..Math.Min(prefix.Length + 3, 8 - tail.Length)] + tail; + return ext.Length > 0 ? baseName + "." + ext : baseName; + } + + /// + /// Alias de todos los nombres de una carpeta. Si dos coinciden, el segundo en orden alfabetico + /// pasa a ~2, y asi sucesivamente (muy raro). + /// + public static Dictionary Map(IEnumerable names) + { + var result = new Dictionary(StringComparer.Ordinal); + var used = new HashSet(StringComparer.OrdinalIgnoreCase); + var ordered = names.OrderBy(n => n, StringComparer.Ordinal).ToList(); + // Primero los que ya son 8.3: conservan su nombre tal cual (DOS no distingue mayusculas). + foreach (var name in ordered.Where(Is83)) + { + result[name] = name; + used.Add(result[name]); + } + foreach (var name in ordered.Where(n => !Is83(n))) + { + for (int n = 1; ; n++) + { + string alias = Alias(name, n); + if (used.Add(alias)) + { + result[name] = alias; + break; + } + } + } + return result; + } + + /// Nombre real dentro de una carpeta a partir de su alias (null si no hay ninguno). + public static string? Resolve(string physicalDirectory, string alias) + { + if (!Directory.Exists(physicalDirectory)) + return null; + var names = new DirectoryInfo(physicalDirectory).EnumerateFileSystemInfos().Select(i => i.Name).ToList(); + foreach (var (name, a) in Map(names)) + if (a.Equals(alias, StringComparison.OrdinalIgnoreCase)) + return name; + return null; + } + + /// Solo los caracteres validos en 8.3, transliterados a ASCII y en mayusculas. + private static string Clean(string text, int max) + { + var sb = new StringBuilder(); + foreach (char c in text.Normalize(NormalizationForm.FormD)) + { + if (CharUnicodeInfo.GetUnicodeCategory(c) == UnicodeCategory.NonSpacingMark) + continue; + char u = char.ToUpperInvariant(c); + if (Allowed.Contains(u) && u != '~') + sb.Append(u); + if (sb.Length == max) + break; + } + return sb.ToString(); + } + + private static uint Fnv1a(string text) + { + uint hash = 2166136261; + foreach (byte b in Encoding.UTF8.GetBytes(text)) + { + hash ^= b; + hash *= 16777619; + } + return hash; + } +} diff --git a/src/Sunfish.Core/Strings.cs b/src/Sunfish.Core/Strings.cs new file mode 100644 index 0000000..7d92a7c --- /dev/null +++ b/src/Sunfish.Core/Strings.cs @@ -0,0 +1,188 @@ +using System.Runtime.InteropServices; +using Sunfish.Shares; + +namespace Sunfish; + +/// Textos de usuario en espanol e ingles. +public static partial class Strings +{ + public static bool Spanish { get; private set; } + + static Strings() => Detect(); + + /// SUNFISH_LANG manda; si no, el locale (LC_ALL, LC_MESSAGES, LANG) o el idioma de Windows. + public static void Detect() + { + string? lang = FirstNonEmpty("SUNFISH_LANG", "LC_ALL", "LC_MESSAGES", "LANG"); + if (lang is null && OperatingSystem.IsWindows()) + lang = (GetUserDefaultUILanguage() & 0x3FF) == 0x0A ? "es" : "en"; + Spanish = lang is not null && lang.StartsWith("es", StringComparison.OrdinalIgnoreCase); + } + + /// Fuerza el idioma ("es" o "en"), p. ej. desde --lang. + public static void Force(string lang) => + Spanish = lang.StartsWith("es", StringComparison.OrdinalIgnoreCase); + + [LibraryImport("kernel32.dll")] + private static partial ushort GetUserDefaultUILanguage(); + + private static string? FirstNonEmpty(params string[] variables) + { + foreach (var name in variables) + { + var value = Environment.GetEnvironmentVariable(name); + if (!string.IsNullOrWhiteSpace(value)) + return value; + } + return null; + } + + private static string T(string english, string spanish) => Spanish ? spanish : english; + + // Web: navegador de directorios + public static string ColName => T("Name", "Nombre"); + public static string ColSize => T("Size", "Tamaño"); + public static string ColDate => T("Date", "Fecha"); + public static string ParentFolder => T("Parent folder", "Carpeta superior"); + public static string EmptyFolder => T("(empty folder)", "(carpeta vacía)"); + public static string Shares => T("Shares", "Carpetas compartidas"); + public static string FolderContents => T("contents", "contenido"); + + // Web: errores + public static string ErrorTitle(int status) => status switch + { + 400 => T("Bad request", "Petición incorrecta"), + 403 => T("Forbidden", "Prohibido"), + 404 => T("Not found", "No encontrado"), + 405 => T("Method not allowed", "Método no permitido"), + 416 => T("Range not satisfiable", "Rango no válido"), + 401 => T("Authentication required", "Hace falta identificarse"), + 409 => T("Conflict", "Conflicto"), + 413 => T("Too large", "Demasiado grande"), + 429 => T("Too many attempts", "Demasiados intentos"), + 507 => T("Not enough space", "No hay espacio suficiente"), + _ => T("Server error", "Error del servidor"), + }; + + public static string ErrorText(int status) => status switch + { + 401 => T("Enter a valid user name and password.", "Escribe un usuario y una contraseña válidos."), + 403 => T("This action is not allowed here.", "Esta acción no está permitida aquí."), + 404 => T("The requested address does not exist.", "La dirección pedida no existe."), + 429 => T("Too many failed attempts. Wait a minute and try again.", "Demasiados intentos fallidos. Espera un minuto y vuelve a probar."), + _ => "", + }; + + // Web: acciones + public static string Upload => T("Upload", "Subir"); + public static string UploadHint => T("Upload files (or drop them on the page)", "Subir ficheros (o suéltalos en la página)"); + public static string Uploading => T("Uploading", "Subiendo"); + public static string UploadDone => T("Upload finished", "Subida terminada"); + public static string NewFolder => T("New folder", "Nueva carpeta"); + public static string Create => T("Create", "Crear"); + public static string Rename => T("Rename", "Renombrar"); + public static string RenameShort => T("ren", "ren"); + public static string Delete => T("Delete", "Borrar"); + public static string DeleteShort => T("del", "borrar"); + public static string Cancel => T("Cancel", "Cancelar"); + public static string DownloadZip => T("Download folder as ZIP", "Descargar la carpeta en ZIP"); + public static string RenameTitle(string name) => T($"Rename {name}", $"Renombrar {name}"); + public static string NewName => T("New name:", "Nombre nuevo:"); + public static string DeleteFileQuestion(string name) => + T($"Delete the file {name}? This cannot be undone.", $"¿Borrar el fichero {name}? No se puede deshacer."); + public static string DeleteFolderQuestion(string name) => + T($"Delete the folder {name} and everything inside it? This cannot be undone.", + $"¿Borrar la carpeta {name} y todo su contenido? No se puede deshacer."); + public static string BadToken => T("The form has expired. Go back, reload the page and try again.", + "El formulario ha caducado. Vuelve atrás, recarga la página y prueba otra vez."); + public static string ZipTooLarge => T("The folder is too large for a ZIP file (4 GB or 65535 entries).", + "La carpeta es demasiado grande para un ZIP (4 GB o 65535 entradas)."); + + public static string OpResult(FileOpResult result, string name) => result switch + { + FileOpResult.InvalidName => T($"'{name}' is not a valid name.", $"'{name}' no es un nombre válido."), + FileOpResult.NotFound => T($"'{name}' does not exist.", $"'{name}' no existe."), + FileOpResult.Exists => T($"'{name}' already exists.", $"'{name}' ya existe."), + FileOpResult.NoSpace => T("There is not enough free space.", "No hay espacio libre suficiente."), + FileOpResult.Conflict => T($"The upload of '{name}' got out of step; start it again.", $"La subida de '{name}' se ha desincronizado; empiézala de nuevo."), + _ => T($"'{name}' could not be changed.", $"No se ha podido modificar '{name}'."), + }; + + // Configuracion + public static string ConfigLine(int line, string message) => + T($"line {line}: {message}", $"línea {line}: {message}"); + public static string ConfigBadLine => T("not a section, comment or key = value", "no es una sección, comentario ni clave = valor"); + public static string ConfigBadBool(string key, string value) => + T($"{key}: '{value}' is not true/false", $"{key}: '{value}' no es true/false"); + public static string ConfigBadNumber(string key, string value) => + T($"{key}: '{value}' is not a valid number", $"{key}: '{value}' no es un número válido"); + public static string ConfigBadAddress(string value) => + T($"Bind: '{value}' is not an IP address", $"Bind: '{value}' no es una dirección IP"); + public static string ConfigBadLocation(string share, string location) => + T($"share '{share}': invalid Location '{location}'", $"share '{share}': Location '{location}' no válida"); + public static string ConfigDuplicateLocation(string share, string location) => + T($"share '{share}': Location '{location}' is already used", $"share '{share}': la Location '{location}' ya está en uso"); + public static string ConfigMissingPath(string share) => + T($"share '{share}': missing Path", $"share '{share}': falta Path"); + public static string ConfigUnknownSection(string name) => + T($"unknown section [{name}]", $"sección desconocida [{name}]"); + + // Servidor + public static string HttpListening(string endpoint) => + T($"HTTP listening on {endpoint}", $"HTTP escuchando en {endpoint}"); + public static string HttpStopped => T("HTTP stopped", "HTTP detenido"); + public static string ConnectionError(string remote, string message) => + T($"{remote}: connection error: {message}", $"{remote}: error de conexión: {message}"); + public static string FtpListening(string endpoint) => + T($"FTP listening on {endpoint}", $"FTP escuchando en {endpoint}"); + public static string FtpStopped => T("FTP stopped", "FTP detenido"); + public static string MdnsAnnouncing(string instance) => + T($"Announced on the LAN as '{instance}'", $"Anunciado en la red local como '{instance}'"); + public static string MdnsUnavailable(string message) => + T($"Cannot announce on the LAN (mDNS port 5353): {message}", $"No se puede anunciar en la red local (puerto mDNS 5353): {message}"); + public static string CannotListen(int port, string message) => + T($"Cannot listen on port {port}: {message}", $"No se puede escuchar en el puerto {port}: {message}"); + public static string ConfigReloaded => T("Configuration reloaded", "Configuración recargada"); + public static string ConfigReloadError(string error) => + T($"Configuration: {error}", $"Configuración: {error}"); + public static string UnknownShareKey(string key) => + T($"unknown option '{key}'", $"opción desconocida '{key}'"); + + // Comunes a las aplicaciones (GUI y TUI) + public static string AppShares => T("Shares", "Shares"); + public static string AppUsers => T("Users", "Usuarios"); + public static string AppLog => T("Log", "Registro"); + public static string AppStatus => T("Status", "Estado"); + public static string AppRunning => T("Running", "En marcha"); + public static string AppStopped => T("Stopped", "Parado"); + public static string AppPort => T("HTTP port", "Puerto HTTP"); + public static string AppAddresses => T("Addresses", "Direcciones"); + public static string AppNoShares => T("No shares yet. Add a folder to share it.", "Aún no hay shares. Añade una carpeta para compartirla."); + public static string AppQuickShare(string path) => + T($"Quick share (not saved): {path}", $"Compartido al momento (no se guarda): {path}"); + public static string FieldName => T("Name", "Nombre"); + public static string FieldLocation => T("Location (URL path)", "Location (ruta en la URL)"); + public static string FieldPath => T("Folder", "Carpeta"); + public static string FieldIndex => T("Index document (empty: none)", "Documento índice (vacío: ninguno)"); + public static string FieldUsers => T("Users (comma separated; empty: public)", "Usuarios (separados por comas; vacío: público)"); + public static string FieldPassword => T("Password", "Contraseña"); + public static string OptEnabled => T("Enabled", "Activo"); + public static string OptListing => T("List folders", "Listar carpetas"); + public static string OptUpload => T("Upload and create folders", "Subir y crear carpetas"); + public static string OptOverwrite => T("Overwrite existing files", "Sobrescribir ficheros"); + public static string OptRename => T("Rename", "Renombrar"); + public static string OptDelete => T("Delete", "Borrar"); + public static string OptZip => T("Download folders as ZIP", "Descargar carpetas en ZIP"); + public static string WarnUploadListing => T( + "Upload + listing: anyone can publish web pages in this share.", + "Subir + listar: cualquiera puede publicar páginas web en este share."); + public static string AskDeleteShare(string name) => + T($"Remove the share '{name}'? The folder and its files are not touched.", + $"¿Quitar el share '{name}'? La carpeta y sus ficheros no se tocan."); + public static string AskDeleteUser(string name) => + T($"Delete the user '{name}'?", $"¿Borrar el usuario '{name}'?"); + public static string ErrorNameInUse(string name) => T($"'{name}' is already in use.", $"'{name}' ya está en uso."); + public static string ErrorBadName(string name) => T($"Invalid name: '{name}'.", $"Nombre no válido: '{name}'."); + public static string ErrorNotAFolder(string path) => T($"Not a folder: {path}", $"No es una carpeta: {path}"); + public static string ErrorPasswordEmpty => T("The password cannot be empty.", "La contraseña no puede estar vacía."); +} diff --git a/src/Sunfish.Core/StringsAdmin.cs b/src/Sunfish.Core/StringsAdmin.cs new file mode 100644 index 0000000..4fd0470 --- /dev/null +++ b/src/Sunfish.Core/StringsAdmin.cs @@ -0,0 +1,77 @@ +namespace Sunfish; + +/// Textos del panel de administracion, de la importacion de la 2.0 y del QR. +public static partial class Strings +{ + // Panel web + public static string AdminTitle(string server) => T($"{server} - administration", $"{server} - administración"); + public static string AdminHome => T("Status", "Estado"); + public static string AdminStatus => T("Status", "Estado"); + public static string AdminServer => T("Server", "Servidor"); + public static string AdminUsers => T("Users", "Usuarios"); + public static string AdminPanel => T("Administration panel", "Panel de administración"); + public static string AdminNewShare => T("New share", "Nuevo share"); + public static string AdminEditShare(string name) => T($"Share '{name}'", $"Share '{name}'"); + public static string AdminEdit => T("edit", "editar"); + public static string AdminDisabled => T("disabled", "desactivado"); + public static string AdminOnPort(int port) => T($"running on port {port}", $"en marcha en el puerto {port}"); + public static string AdminOff => T("stopped", "parado"); + public static string AdminRootMenu => T("Show the list of shares at /", "Mostrar la lista de shares en /"); + public static string AdminFtp => T("FTP server", "Servidor FTP"); + public static string AdminFtpPort => T("FTP port", "Puerto FTP"); + public static string AdminHttpFromApp => T( + "The HTTP port and interfaces are changed from the application: changing them here would cut this connection.", + "El puerto y las interfaces HTTP se cambian desde la aplicación: cambiarlos aquí cortaría esta conexión."); + public static string AdminMac => T("Classic Mac (MacBinary/BinHex)", "Mac clásico (MacBinary/BinHex)"); + public static string AdminShortNames => T("8.3 names for DOS and Windows 3.1", "Nombres 8.3 para DOS y Windows 3.1"); + public static string AdminAllowedUsers => T("Users (none: public)", "Usuarios (ninguno: público)"); + public static string AdminNewUser => T("New user", "Usuario nuevo"); + public static string AdminRepeat => T("Repeat", "Repetir"); + public static string AdminChange => T("Change", "Cambiar"); + public static string AdminAdd => T("Add", "Añadir"); + public static string AdminSave => T("Save", "Guardar"); + public static string AdminSaved => T("Saved.", "Guardado."); + public static string AdminAllowFrom => T("Allowed from", "Permitido desde"); + public static string AdminAllowHelp => T( + "Addresses or networks separated by commas: 127.0.0.1, ::1, 192.168.1.0/24, or * for any. The current password is required to change this section.", + "Direcciones o redes separadas por comas: 127.0.0.1, ::1, 192.168.1.0/24, o * para cualquiera. Para cambiar esta sección hace falta la contraseña actual."); + public static string AdminCurrentPassword => T("Current password", "Contraseña actual"); + public static string AdminNewPassword => T("New password (optional)", "Contraseña nueva (opcional)"); + public static string AdminCleartext => T( + "Warning: this page and its password travel unencrypted over the network.", + "Aviso: esta página y su contraseña viajan sin cifrar por la red."); + public static string AdminExpired => T("The form has expired; try again.", "El formulario ha caducado; vuelve a intentarlo."); + public static string AdminBadPort => T("The FTP port must be between 1024 and 65535.", "El puerto FTP debe estar entre 1024 y 65535."); + public static string AdminWrongCurrent => T("The current password is not correct.", "La contraseña actual no es correcta."); + public static string AdminBadAllow(string rule) => T($"Invalid address or network: '{rule}'.", $"Dirección o red no válida: '{rule}'."); + public static string AdminLockout => T( + "That list does not include the address you are connecting from; it would lock you out.", + "Esa lista no incluye la dirección desde la que te conectas; te dejaría fuera."); + public static string AdminPasswordMismatch => T("The passwords do not match.", "Las contraseñas no coinciden."); + public static string AdminApplyFailed => T( + "Saved, but a service could not be restarted (see the log).", + "Guardado, pero no se pudo volver a arrancar un servicio (ver el registro)."); + public static string AdminUrl(string url) => T($"Administration panel: {url}", $"Panel de administración: {url}"); + + // Importacion de la 2.0 + public static string ImportPortChanged(int old, int now) => T( + $"Port {old} needs administrator rights; using {now} instead.", + $"El puerto {old} necesita permisos de administrador; se usa {now}."); + public static string ImportAdminIgnored => T( + "The 2.0 admin panel password is not imported (it was stored in clear text); set a new one if you want the panel.", + "La contraseña del panel de la 2.0 no se importa (se guardaba en claro); pon una nueva si quieres el panel."); + public static string ImportServiceSkipped(string name, string type) => + T($"'{name}' ({type}) is not a folder share; skipped.", $"'{name}' ({type}) no es una carpeta compartida; se omite."); + public static string ImportExecIgnored(string name) => T( + $"'{name}': running programs on the server no longer exists; ignored.", + $"'{name}': ejecutar programas en el servidor ya no existe; se ignora."); + public static string ImportMissingFolder(string name, string path) => + T($"'{name}': the folder does not exist: {path}", $"'{name}': la carpeta no existe: {path}"); + public static string ImportQuestion(string file) => T( + $"A Sunfish 2.0 configuration was found ({file}). Import its shares?", + $"Se ha encontrado una configuración de Sunfish 2.0 ({file}). ¿Importar sus shares?"); + public static string ImportDone(int shares, string path) => + T($"Imported {shares} share(s) into {path}", $"Importados {shares} share(s) en {path}"); + public static string ImportInvalid(string file) => + T($"Not a Sunfish 2.0 configuration: {file}", $"No es una configuración de Sunfish 2.0: {file}"); +} diff --git a/src/Sunfish.Core/Sunfish.Core.csproj b/src/Sunfish.Core/Sunfish.Core.csproj new file mode 100644 index 0000000..29b0380 --- /dev/null +++ b/src/Sunfish.Core/Sunfish.Core.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + Sunfish + 3.0.0 + XWolfOverride + true + + + true + $(WarningsAsErrors);IL2026;IL2046;IL2057;IL2067;IL2070;IL2072;IL2075;IL2087;IL2091;IL3050;IL3051 + + + + + + + + + + + + diff --git a/src/Sunfish.Core/Web/ByteRange.cs b/src/Sunfish.Core/Web/ByteRange.cs new file mode 100644 index 0000000..d9a21fb --- /dev/null +++ b/src/Sunfish.Core/Web/ByteRange.cs @@ -0,0 +1,59 @@ +using System.Globalization; + +namespace Sunfish.Web; + +/// Un rango de bytes pedido con "Range: bytes=..." (solo se admite uno). +public readonly record struct ByteRange(long Start, long End) +{ + public long Length => End - Start + 1; + + public enum ParseResult { None, Valid, Unsatisfiable } + + /// + /// Interpreta la cabecera Range para un fichero de bytes. + /// None: sin cabecera, mal formada o con varios rangos (se responde el fichero entero). + /// + public static ParseResult TryParse(string? header, long size, out ByteRange range) + { + range = default; + if (string.IsNullOrWhiteSpace(header)) + return ParseResult.None; + header = header.Trim(); + if (!header.StartsWith("bytes=", StringComparison.OrdinalIgnoreCase)) + return ParseResult.None; + string spec = header[6..].Trim(); + if (spec.Contains(',')) + return ParseResult.None; + int dash = spec.IndexOf('-'); + if (dash < 0) + return ParseResult.None; + string first = spec[..dash].Trim(); + string last = spec[(dash + 1)..].Trim(); + + if (first.Length == 0) + { + // "-N": los ultimos N bytes + if (!long.TryParse(last, NumberStyles.None, CultureInfo.InvariantCulture, out long suffix)) + return ParseResult.None; + if (suffix == 0 || size == 0) + return ParseResult.Unsatisfiable; + long start = Math.Max(0, size - suffix); + range = new ByteRange(start, size - 1); + return ParseResult.Valid; + } + + if (!long.TryParse(first, NumberStyles.None, CultureInfo.InvariantCulture, out long from)) + return ParseResult.None; + long to = size - 1; + if (last.Length > 0) + { + if (!long.TryParse(last, NumberStyles.None, CultureInfo.InvariantCulture, out to) || to < from) + return ParseResult.None; + to = Math.Min(to, size - 1); + } + if (from >= size) + return ParseResult.Unsatisfiable; + range = new ByteRange(from, to); + return ParseResult.Valid; + } +} diff --git a/src/Sunfish.Core/Web/ClientProfile.cs b/src/Sunfish.Core/Web/ClientProfile.cs new file mode 100644 index 0000000..e29bc8c --- /dev/null +++ b/src/Sunfish.Core/Web/ClientProfile.cs @@ -0,0 +1,85 @@ +namespace Sunfish.Web; + +/// Tipo de cliente; decide formato de pagina, juego de caracteres y nombres (ver DOC/CLIENTES.md). +public enum ClientKind { Modern, Classic, Text, Dos, Win16, Mac } + +public sealed record ClientProfile(ClientKind Kind, bool Forced) +{ + /// Pagina en modo moderno (UTF-8, CSS); el resto usa el modo clasico. + public bool IsModern => Kind == ClientKind.Modern; + + /// Navegador de texto: listados como lineas, sin depender de tablas ni imagenes. + public bool IsText => Kind == ClientKind.Text || Kind == ClientKind.Dos; + + /// DOS y Windows 3.1: solo entienden nombres 8.3 (se les muestran alias). + public bool UsesShortNames => Kind is ClientKind.Dos or ClientKind.Win16; + + /// Valor para ?p= (se propaga en los enlaces cuando el perfil se forzo). + public string Code => Kind switch + { + ClientKind.Modern => "modern", + ClientKind.Text => "text", + ClientKind.Dos => "dos", + ClientKind.Win16 => "win16", + ClientKind.Mac => "mac", + _ => "classic", + }; + + public static bool TryParseCode(string? code, out ClientKind kind) + { + kind = ClientKind.Classic; + switch (code?.ToLowerInvariant()) + { + case "modern": kind = ClientKind.Modern; return true; + case "classic": kind = ClientKind.Classic; return true; + case "text": kind = ClientKind.Text; return true; + case "dos": kind = ClientKind.Dos; return true; + case "win16": kind = ClientKind.Win16; return true; + case "mac": kind = ClientKind.Mac; return true; + default: return false; + } + } + + /// + /// Perfil por ?p= (forzado) o por el User-Agent. La lista de firmas es orientativa y se + /// amplia al probar con clientes reales; lo dudoso cae en Classic, que funciona en todos. + /// + public static ClientProfile Detect(string? userAgent, string? forced) + { + if (TryParseCode(forced, out var kind)) + return new ClientProfile(kind, true); + return new ClientProfile(FromUserAgent(userAgent), false); + } + + internal static ClientKind FromUserAgent(string? ua) + { + if (string.IsNullOrWhiteSpace(ua)) + return ClientKind.Classic; // herramientas HTTP/1.0 y clientes que no se identifican + + if (Has(ua, "DOS") || Has(ua, "Arachne") || Has(ua, "DOSLynx")) + return ClientKind.Dos; + if (Has(ua, "Lynx") || Has(ua, "Links") || Has(ua, "w3m")) + return ClientKind.Text; + if (Has(ua, "Win16") || Has(ua, "Windows 3.1") || Has(ua, "Windows 3.11")) + return ClientKind.Win16; + if (IsClassicMac(ua)) + return ClientKind.Mac; + if (IsModernAgent(ua)) + return ClientKind.Modern; + return ClientKind.Classic; + } + + private static bool IsClassicMac(string ua) + { + if (Has(ua, "Mac OS X") || Has(ua, "Intel Mac") || Has(ua, "iPhone") || Has(ua, "iPad")) + return false; + return Has(ua, "Mac_PowerPC") || Has(ua, "Mac_PPC") || Has(ua, "Mac_68000") || Has(ua, "Mac_68K") || + Has(ua, "MacWeb") || (Has(ua, "Macintosh") && (Has(ua, "PPC") || Has(ua, "68K"))); + } + + private static bool IsModernAgent(string ua) => + ua.StartsWith("Mozilla/5.0", StringComparison.Ordinal) && + (Has(ua, "AppleWebKit/") || Has(ua, "Gecko/") || Has(ua, "Trident/7")); + + private static bool Has(string ua, string token) => ua.Contains(token, StringComparison.OrdinalIgnoreCase); +} diff --git a/src/Sunfish.Core/Web/CsrfTokens.cs b/src/Sunfish.Core/Web/CsrfTokens.cs new file mode 100644 index 0000000..11d45c9 --- /dev/null +++ b/src/Sunfish.Core/Web/CsrfTokens.cs @@ -0,0 +1,38 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Sunfish.Web; + +/// +/// Tokens anti-CSRF sin cookies (algunos navegadores antiguos no las tienen): HMAC-SHA256 de +/// (accion, carpeta, hora) con una clave aleatoria de esta ejecucion. Otra web no puede leer +/// la pagina, asi que no puede conocer el token ni falsificar el formulario. Vale durante la +/// hora en curso y la anterior. +/// +public sealed class CsrfTokens +{ + private readonly byte[] _key = RandomNumberGenerator.GetBytes(32); + + internal Func Now { get; init; } = () => DateTime.UtcNow; + + public string Create(string action, string folder) => Compute(action, folder, Hour(Now())); + + public bool Validate(string? token, string action, string folder) + { + if (string.IsNullOrEmpty(token)) + return false; + long hour = Hour(Now()); + return Equal(token, Compute(action, folder, hour)) || Equal(token, Compute(action, folder, hour - 1)); + } + + private string Compute(string action, string folder, long hour) + { + byte[] mac = HMACSHA256.HashData(_key, Encoding.UTF8.GetBytes($"{action}\n{folder}\n{hour}")); + return Convert.ToBase64String(mac, 0, 16).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } + + private static long Hour(DateTime utc) => utc.Ticks / TimeSpan.TicksPerHour; + + private static bool Equal(string a, string b) => + CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(a), Encoding.ASCII.GetBytes(b)); +} diff --git a/src/Sunfish.Core/Web/DirectoryPage.cs b/src/Sunfish.Core/Web/DirectoryPage.cs new file mode 100644 index 0000000..37671d4 --- /dev/null +++ b/src/Sunfish.Core/Web/DirectoryPage.cs @@ -0,0 +1,344 @@ +using System.Globalization; +using Sunfish.Shares; + +namespace Sunfish.Web; + +/// Una fila del navegador de directorios. +internal sealed record ListingItem(FileEntry Entry, bool HasIndex, string? Alias = null, bool MacFork = false); + +/// Lo que se puede hacer en la carpeta mostrada, con el token anti-CSRF de cada accion. +internal sealed class FolderActions +{ + public bool Upload { get; init; } + public bool Rename { get; init; } + public bool Delete { get; init; } + public bool Zip { get; init; } + public Func Token { get; init; } = _ => ""; + + public bool Any => Upload || Rename || Delete || Zip; +} + +/// +/// Paginas del navegador de directorios. El mismo HTML sirve a todos: HTML 3.2 con tablas y +/// formularios, sin JavaScript; el modo moderno anade UTF-8, un CSS sencillo y un script que solo +/// mejora la subida (arrastrar y soltar, progreso, trozos). Los navegadores de texto reciben una +/// lista. La fuente es sin serifa (CSS en moderno, FONT FACE en clasico: Netscape no hereda la +/// fuente dentro de las tablas, por eso va en cada celda). +/// +internal static class DirectoryPage +{ + internal const string Font = ""; + internal const string EndFont = ""; + + private const string ModernStyle = + "body{font-family:Verdana,\"DejaVu Sans\",Tahoma,Arial,Helvetica,sans-serif;max-width:1000px;margin:0 auto;padding:8px;color:#000;background:#fff}" + + "table.list{border-collapse:collapse;width:100%}" + + "table.list td,table.list th{padding:4px 6px}" + + "table.list tr:nth-child(even){background:#f4f7fb}" + + "a{text-decoration:none;color:#1a4f8a}a:hover{text-decoration:underline}" + + ".extra{font-size:smaller;color:#555}" + + "form{margin:6px 0}" + + "body.sf-drop{outline:4px dashed #1a4f8a;outline-offset:-4px}" + + "#sf-status{font-weight:bold}" + + "@media (max-width:600px){.date{display:none}}"; + + public sealed class Model + { + public string ServerName { get; init; } = "Sunfish"; + public IReadOnlyList Segments { get; init; } = []; + public IReadOnlyList Items { get; init; } = []; + public ClientProfile Profile { get; init; } = new(ClientKind.Classic, false); + public string Sort { get; init; } = "n"; + public FolderActions Actions { get; init; } = new(); + public bool IsRoot => Segments.Count == 0; + + /// URL de la carpeta, codificada y con barra final. + public string FolderUrl => UrlOf(Segments); + } + + public static HtmlBuilder Render(Model m) + { + var html = new HtmlBuilder(classic: !m.Profile.IsModern); + string title = m.IsRoot ? m.ServerName : m.ServerName + " - /" + string.Join('/', m.Segments) + "/"; + Header(html, title); + if (m.Profile.IsText) + RenderText(html, m); + else + RenderTable(html, m); + RenderForms(html, m); + if (!html.Classic && m.Actions.Upload) + html.Raw("\n"); + Footer(html); + return html; + } + + public static HtmlBuilder RenderError(int status, string serverName, ClientProfile profile, string? detail = null) + { + var html = new HtmlBuilder(classic: !profile.IsModern); + Header(html, serverName + " - " + status); + html.Raw("

").Raw(Font).Raw(status.ToString(CultureInfo.InvariantCulture)).Raw(" ").Text(Strings.ErrorTitle(status)).Raw(EndFont).Raw("

\n"); + string text = detail ?? Strings.ErrorText(status); + if (text.Length > 0) + html.Raw("

").Raw(Font).Text(text).Raw(EndFont).Raw("

\n"); + html.Raw("

").Raw(Font).Raw("/").Raw(EndFont).Raw("

\n"); + Footer(html); + return html; + } + + /// Pagina para escribir el nombre nuevo (sin JavaScript). + public static HtmlBuilder RenderRename(Model m, string name) + { + var html = new HtmlBuilder(classic: !m.Profile.IsModern); + Header(html, m.ServerName + " - " + Strings.Rename); + html.Raw("

").Raw(Font).Text(Strings.RenameTitle(name)).Raw(EndFont).Raw("

\n"); + FormStart(html, m, "rename", multipart: false); + html.Raw("") + .Raw(Font).Text(Strings.NewName).Raw(EndFont).Raw(" ") + .Raw("\n"); + CancelLink(html, m); + Footer(html); + return html; + } + + /// Confirmacion de borrado (sin JavaScript). + public static HtmlBuilder RenderDelete(Model m, string name, bool isDirectory) + { + var html = new HtmlBuilder(classic: !m.Profile.IsModern); + Header(html, m.ServerName + " - " + Strings.Delete); + html.Raw("

").Raw(Font).Text(Strings.Delete).Raw(EndFont).Raw("

\n

").Raw(Font) + .Text(isDirectory ? Strings.DeleteFolderQuestion(name) : Strings.DeleteFileQuestion(name)).Raw(EndFont).Raw("

\n"); + FormStart(html, m, "delete", multipart: false); + html.Raw("") + .Raw("\n"); + CancelLink(html, m); + Footer(html); + return html; + } + + private static void CancelLink(HtmlBuilder html, Model m) => + html.Raw("

").Raw(Font).Raw("") + .Text(Strings.Cancel).Raw("").Raw(EndFont).Raw("

\n"); + + internal static void Header(HtmlBuilder html, string title) + { + html.Raw("\n") + .Text(title).Raw("\n") + .Raw("\n"); + if (!html.Classic) + html.Raw("\n\n"); + html.Raw("\n\n"); + } + + internal static void Footer(HtmlBuilder html) => + html.Raw("
\n").Raw(Font).Raw("Sunfish 3.0").Raw(EndFont).Raw("\n\n"); + + private static void RenderTable(HtmlBuilder html, Model m) + { + string q = ProfileQuery(m.Profile, first: true); + + html.Raw("") + .Raw("") + .Raw("
\"Sunfish\"").Raw(Font).Raw("").Text(m.ServerName).Raw("
"); + Breadcrumb(html, m, q); + html.Raw(EndFont).Raw("
\n
\n"); + + bool rowActions = m.Actions.Rename || m.Actions.Delete; + html.Raw("\n") + .Raw(""); + HeaderCell(html, "LEFT", "n", Strings.ColName, m, ""); + HeaderCell(html, "RIGHT", "s", Strings.ColSize, m, ""); + HeaderCell(html, "LEFT", "d", Strings.ColDate, m, " CLASS=\"date\""); + if (rowActions) + html.Raw(""); + html.Raw("\n"); + string emptyActions = rowActions ? "" : ""; + + if (!m.IsRoot) + { + html.Raw("").Raw(emptyActions).Raw("\n"); + } + + int row = 0; + foreach (var item in Sorted(m.Items, m.Sort)) + { + var e = item.Entry; + string shown = item.Alias ?? e.Name; + string href = HtmlBuilder.UrlSegment(shown) + (e.IsDirectory ? "/" : ""); + string bg = row++ % 2 == 1 && html.Classic ? " BGCOLOR=\"#F4F7FB\"" : ""; + html.Raw(""); + if (rowActions) + { + html.Raw(""); + } + html.Raw("\n"); + } + if (m.Items.Count == 0) + html.Raw("").Raw(emptyActions).Raw("\n"); + html.Raw("
").Raw(Font).Raw("\"[..]\" ..") + .Raw(EndFont).Raw("
").Raw(Font) + .Raw("") + .Raw(e.IsDirectory + ? "\"[DIR]\" " + : "\"[ ") + .Text(shown).Raw(e.IsDirectory ? "/" : "").Raw(""); + Extras(html, m, item, href); + if (item.HasIndex) + html.Raw(" [").Text(Strings.FolderContents).Raw("]"); + html.Raw(EndFont).Raw("").Raw(Font) + .Text(e.IsDirectory ? "" : FormatSize(e.Size)).Raw(EndFont) + .Raw("").Raw(Font) + .Text(e.IsVirtual ? "" : FormatDate(e.Modified)).Raw(EndFont).Raw("").Raw(Font).Raw(""); + if (!e.IsVirtual) + RowActionLinks(html, m, e.Name); + html.Raw("").Raw(EndFont).Raw("
").Raw(Font).Raw("").Text(Strings.EmptyFolder).Raw("").Raw(EndFont).Raw("
\n"); + } + + private static void RenderText(HtmlBuilder html, Model m) + { + string q = ProfileQuery(m.Profile, first: true); + html.Raw("

").Text(m.ServerName).Raw("

\n

"); + Breadcrumb(html, m, q); + html.Raw("

\n
    \n"); + if (!m.IsRoot) + html.Raw("
  • .. (").Text(Strings.ParentFolder).Raw(")\n"); + foreach (var item in Sorted(m.Items, m.Sort)) + { + var e = item.Entry; + string shown = item.Alias ?? e.Name; + string href = HtmlBuilder.UrlSegment(shown) + (e.IsDirectory ? "/" : ""); + html.Raw("
  • ").Text(shown).Raw(e.IsDirectory ? "/" : "").Raw(""); + Extras(html, m, item, href); + if (!e.IsDirectory) + html.Raw(" ").Text(FormatSize(e.Size)); + if (!e.IsVirtual) + { + html.Raw(" ").Text(FormatDate(e.Modified)); + RowActionLinks(html, m, e.Name); + } + html.Raw("\n"); + } + if (m.Items.Count == 0) + html.Raw("
  • ").Text(Strings.EmptyFolder).Raw("\n"); + html.Raw("
\n"); + } + + /// + /// Tras el nombre: el nombre largo si se muestra un alias 8.3, y para quien no es un Mac + /// clasico los enlaces para bajar el fichero con su resource fork (MacBinary o BinHex). + /// + private static void Extras(HtmlBuilder html, Model m, ListingItem item, string href) + { + if (item.Alias is not null && item.Alias != item.Entry.Name) + html.Raw(" (").Text(item.Entry.Name).Raw(")"); + if (item.MacFork && m.Profile.Kind != ClientKind.Mac) + { + string pq = ProfileQuery(m.Profile, first: false); + html.Raw(" [.bin]") + .Raw(" [.hqx]"); + } + } + + private static void RowActionLinks(HtmlBuilder html, Model m, string name) + { + string encoded = Uri.EscapeDataString(name); + string pq = ProfileQuery(m.Profile, first: false); + if (m.Actions.Rename) + html.Raw(" [").Text(Strings.RenameShort).Raw("]"); + if (m.Actions.Delete) + html.Raw(" [").Text(Strings.DeleteShort).Raw("]"); + } + + /// + /// Formularios de la carpeta. Los campos ocultos van antes que el fichero: los navegadores los + /// envian en ese orden y el servidor valida el token antes de escribir nada en disco. + /// + private static void RenderForms(HtmlBuilder html, Model m) + { + var a = m.Actions; + if (!a.Any) + return; + html.Raw("
\n"); + if (a.Upload) + { + FormStart(html, m, "upload", multipart: true); + html.Raw(Font).Text(Strings.UploadHint).Raw(":").Raw(EndFont).Raw("
") + .Raw(" ") + .Raw("\n"); + FormStart(html, m, "mkdir", multipart: false); + html.Raw(Font).Text(Strings.NewFolder).Raw(":").Raw(EndFont) + .Raw(" \n"); + } + if (a.Zip) + { + html.Raw("

").Raw(Font).Raw("[ZIP] ") + .Text(Strings.DownloadZip).Raw("").Raw(EndFont).Raw("

\n"); + } + } + + private static void FormStart(HtmlBuilder html, Model m, string action, bool multipart) + { + html.Raw("
") + .Raw("") + .Raw(""); + } + + private static void Breadcrumb(HtmlBuilder html, Model m, string q) + { + html.Raw("/"); + string link = "/"; + foreach (var segment in m.Segments) + { + link += HtmlBuilder.UrlSegment(segment) + "/"; + html.Raw(" ").Text(segment).Raw(" /"); + } + } + + private static void HeaderCell(HtmlBuilder html, string align, string sort, string label, Model m, string extra) + { + html.Raw("").Raw(Font) + .Raw("") + .Text(label).Raw("").Raw(EndFont).Raw(""); + } + + /// "?p=dos" si el perfil se forzo (los clientes sin cookies lo pierden si no). + internal static string ProfileQuery(ClientProfile profile, bool first) => + profile.Forced ? (first ? "?p=" : "&p=") + profile.Code : ""; + + internal static string UrlOf(IReadOnlyList segments) => + "/" + string.Concat(segments.Select(s => HtmlBuilder.UrlSegment(s) + "/")); + + internal static IEnumerable Sorted(IEnumerable items, string sort) + { + var dirsFirst = items.OrderBy(i => i.Entry.IsDirectory ? 0 : 1); + return sort switch + { + "s" => dirsFirst.ThenBy(i => i.Entry.Size).ThenBy(i => i.Entry.Name, StringComparer.OrdinalIgnoreCase), + "d" => dirsFirst.ThenByDescending(i => i.Entry.Modified).ThenBy(i => i.Entry.Name, StringComparer.OrdinalIgnoreCase), + _ => dirsFirst.ThenBy(i => i.Entry.Name, StringComparer.OrdinalIgnoreCase), + }; + } + + internal static string FormatSize(long bytes) + { + string[] units = ["B", "KB", "MB", "GB", "TB"]; + double value = bytes; + int unit = 0; + while (value >= 1024 && unit < units.Length - 1) + { + value /= 1024; + unit++; + } + return unit == 0 + ? bytes.ToString(CultureInfo.InvariantCulture) + " B" + : value.ToString("0.0", CultureInfo.InvariantCulture) + " " + units[unit]; + } + + internal static string FormatDate(DateTime time) => + time.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture); +} diff --git a/src/Sunfish.Core/Web/HtmlBuilder.cs b/src/Sunfish.Core/Web/HtmlBuilder.cs new file mode 100644 index 0000000..e0e2cb7 --- /dev/null +++ b/src/Sunfish.Core/Web/HtmlBuilder.cs @@ -0,0 +1,91 @@ +using System.Globalization; +using System.Text; + +namespace Sunfish.Web; + +/// +/// Construye HTML escapando todo texto. En modo clasico la pagina es ISO-8859-1 y solo contiene +/// ASCII: los caracteres Latin-1 van como referencias numericas (las entienden hasta los +/// navegadores de HTML 2.0) y el resto se translitera o se cambia por '?'. En modo moderno, UTF-8. +/// +internal sealed class HtmlBuilder(bool classic) +{ + private readonly StringBuilder _sb = new(); + + public bool Classic { get; } = classic; + + public string Charset => Classic ? "iso-8859-1" : "utf-8"; + + public string ContentType => "text/html; charset=" + Charset; + + /// HTML literal (debe ser ASCII). + public HtmlBuilder Raw(string html) + { + _sb.Append(html); + return this; + } + + /// Texto escapado. + public HtmlBuilder Text(string text) + { + _sb.Append(Escape(text, Classic)); + return this; + } + + public byte[] ToBytes() => + Classic ? Encoding.Latin1.GetBytes(_sb.ToString()) : Encoding.UTF8.GetBytes(_sb.ToString()); + + public override string ToString() => _sb.ToString(); + + public static string Escape(string text, bool classic) + { + var sb = new StringBuilder(text.Length + 16); + foreach (char c in text) + { + switch (c) + { + case '&': sb.Append("&"); break; + case '<': sb.Append("<"); break; + case '>': sb.Append(">"); break; + case '"': sb.Append("""); break; + case '\'': sb.Append("'"); break; + default: + if (c < 0x20) + sb.Append('?'); + else if (!classic || c < 0x80) + sb.Append(c); + else if (c is >= ' ' and <= 'ÿ') + sb.Append("&#").Append(((int)c).ToString(CultureInfo.InvariantCulture)).Append(';'); + else + sb.Append(Transliterate(c)); + break; + } + } + return sb.ToString(); + } + + /// Quita los diacriticos ("ő" -> "o"); si no queda un caracter Latin-1, '?'. + internal static string Transliterate(char c) + { + if (char.IsSurrogate(c)) + return "?"; + string decomposed = c.ToString().Normalize(NormalizationForm.FormD); + foreach (char d in decomposed) + { + if (CharUnicodeInfo.GetUnicodeCategory(d) == UnicodeCategory.NonSpacingMark) + continue; + if (d < 0x80) + return d.ToString(); + if (d is >= ' ' and <= 'ÿ') + return "&#" + ((int)d).ToString(CultureInfo.InvariantCulture) + ";"; + break; + } + return "?"; + } + + /// + /// Un componente de URL codificado como %XX de sus bytes UTF-8: solo ASCII, valido en + /// cualquier pagina; el navegador devuelve los mismos bytes al seguir el enlace. + /// + public static string UrlSegment(string name) => Uri.EscapeDataString(name); +} diff --git a/src/Sunfish.Core/Web/MimeTypes.cs b/src/Sunfish.Core/Web/MimeTypes.cs new file mode 100644 index 0000000..cac1f86 --- /dev/null +++ b/src/Sunfish.Core/Web/MimeTypes.cs @@ -0,0 +1,64 @@ +namespace Sunfish.Web; + +/// Tipo MIME por extension. +public static class MimeTypes +{ + public const string Default = "application/octet-stream"; + + private static readonly Dictionary Types = new(StringComparer.OrdinalIgnoreCase) + { + // Texto y web + [".htm"] = "text/html", [".html"] = "text/html", [".shtml"] = "text/html", + [".css"] = "text/css", [".js"] = "text/javascript", [".mjs"] = "text/javascript", + [".json"] = "application/json", [".xml"] = "text/xml", [".xhtml"] = "application/xhtml+xml", + [".txt"] = "text/plain", [".text"] = "text/plain", [".log"] = "text/plain", [".ini"] = "text/plain", + [".md"] = "text/markdown", [".csv"] = "text/csv", [".rtf"] = "application/rtf", + [".nfo"] = "text/plain", [".diz"] = "text/plain", [".bat"] = "text/plain", [".asm"] = "text/plain", + [".c"] = "text/plain", [".h"] = "text/plain", [".pas"] = "text/plain", [".bas"] = "text/plain", + + // Imagenes + [".gif"] = "image/gif", [".jpg"] = "image/jpeg", [".jpeg"] = "image/jpeg", [".jpe"] = "image/jpeg", + [".png"] = "image/png", [".bmp"] = "image/bmp", [".ico"] = "image/x-icon", [".svg"] = "image/svg+xml", + [".webp"] = "image/webp", [".tif"] = "image/tiff", [".tiff"] = "image/tiff", [".xbm"] = "image/x-xbitmap", + [".pcx"] = "image/x-pcx", [".pict"] = "image/x-pict", [".pct"] = "image/x-pict", [".avif"] = "image/avif", + + // Audio y video + [".mp3"] = "audio/mpeg", [".wav"] = "audio/wav", [".ogg"] = "audio/ogg", [".flac"] = "audio/flac", + [".mid"] = "audio/midi", [".midi"] = "audio/midi", [".mod"] = "audio/mod", [".aif"] = "audio/x-aiff", + [".aiff"] = "audio/x-aiff", [".au"] = "audio/basic", [".snd"] = "audio/basic", [".m4a"] = "audio/mp4", + [".mp4"] = "video/mp4", [".m4v"] = "video/mp4", [".webm"] = "video/webm", [".avi"] = "video/x-msvideo", + [".mov"] = "video/quicktime", [".qt"] = "video/quicktime", [".mpg"] = "video/mpeg", [".mpeg"] = "video/mpeg", + [".mkv"] = "video/x-matroska", [".flv"] = "video/x-flv", + + // Documentos + [".pdf"] = "application/pdf", [".ps"] = "application/postscript", [".eps"] = "application/postscript", + [".doc"] = "application/msword", [".xls"] = "application/vnd.ms-excel", [".ppt"] = "application/vnd.ms-powerpoint", + [".docx"] = "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + [".xlsx"] = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + [".pptx"] = "application/vnd.openxmlformats-officedocument.presentationml.presentation", + [".odt"] = "application/vnd.oasis.opendocument.text", [".ods"] = "application/vnd.oasis.opendocument.spreadsheet", + [".wri"] = "application/x-mswrite", [".epub"] = "application/epub+zip", + + // Archivos comprimidos y binarios + [".zip"] = "application/zip", [".gz"] = "application/gzip", [".tgz"] = "application/gzip", + [".tar"] = "application/x-tar", [".7z"] = "application/x-7z-compressed", [".rar"] = "application/vnd.rar", + [".arj"] = "application/x-arj", [".lzh"] = "application/x-lzh", [".lha"] = "application/x-lzh", + [".bz2"] = "application/x-bzip2", [".xz"] = "application/x-xz", [".z"] = "application/x-compress", + [".sit"] = "application/x-stuffit", [".sea"] = "application/x-sea", + [".hqx"] = "application/mac-binhex40", [".bin"] = "application/x-macbinary", [".cpt"] = "application/mac-compactpro", + [".exe"] = "application/x-msdownload", [".com"] = "application/x-msdownload", [".dll"] = "application/x-msdownload", + [".msi"] = "application/x-msi", [".iso"] = "application/x-iso9660-image", [".img"] = "application/octet-stream", + [".dmg"] = "application/x-apple-diskimage", [".deb"] = "application/vnd.debian.binary-package", + [".rpm"] = "application/x-rpm", [".apk"] = "application/vnd.android.package-archive", [".jar"] = "application/java-archive", + [".swf"] = "application/x-shockwave-flash", [".wasm"] = "application/wasm", + + // Fuentes + [".ttf"] = "font/ttf", [".otf"] = "font/otf", [".woff"] = "font/woff", [".woff2"] = "font/woff2", + }; + + public static string For(string fileName) + { + string ext = Path.GetExtension(fileName); + return ext.Length > 0 && Types.TryGetValue(ext, out var type) ? type : Default; + } +} diff --git a/src/Sunfish.Core/Web/MultipartReader.cs b/src/Sunfish.Core/Web/MultipartReader.cs new file mode 100644 index 0000000..8d7ebf0 --- /dev/null +++ b/src/Sunfish.Core/Web/MultipartReader.cs @@ -0,0 +1,276 @@ +using System.Text; + +namespace Sunfish.Web; + +/// Una parte de un formulario multipart/form-data. +internal sealed class MultipartSection +{ + public Dictionary Headers { get; } = new(StringComparer.OrdinalIgnoreCase); + + /// Atributo name de Content-Disposition. + public string Name { get; set; } = ""; + + /// Nombre del fichero tal como llega (puede traer ruta); null si es un campo normal. + public string? FileName { get; set; } + + public Stream Body { get; set; } = Stream.Null; +} + +/// +/// Lee multipart/form-data en flujo, sin cargar los ficheros en memoria: cada parte se lee hasta +/// el siguiente delimitador, que puede llegar partido entre lecturas. Tolera cabeceras de parte +/// acabadas solo en LF. +/// +internal sealed class MultipartReader +{ + private readonly Stream _stream; + private readonly byte[] _delimiter; // "\r\n--" + boundary + private byte[] _buffer = new byte[65536]; + private int _start; + private int _end; + private bool _eof; + private bool _first = true; + private bool _finished; + private SectionStream? _current; + + public MultipartReader(Stream stream, string boundary) + { + _stream = stream; + _delimiter = Encoding.ASCII.GetBytes("\r\n--" + boundary); + // El primer delimitador no lleva CRLF delante: se anade uno para tratarlos todos igual. + _buffer[0] = (byte)'\r'; + _buffer[1] = (byte)'\n'; + _end = 2; + } + + /// boundary de "multipart/form-data; boundary=..." (con o sin comillas). + public static string? GetBoundary(string? contentType) + { + if (contentType is null || !contentType.TrimStart().StartsWith("multipart/form-data", StringComparison.OrdinalIgnoreCase)) + return null; + foreach (var part in contentType.Split(';')) + { + var p = part.Trim(); + if (p.StartsWith("boundary=", StringComparison.OrdinalIgnoreCase)) + { + string value = p[9..].Trim().Trim('"'); + return value.Length is > 0 and <= 200 ? value : null; + } + } + return null; + } + + public async Task NextAsync(CancellationToken ct) + { + if (_finished) + return null; + if (_current is not null) + { + // Lo que no se haya leido de la parte anterior se descarta. + var skip = new byte[16384]; + while (await _current.ReadAsync(skip, ct) > 0) { } + } + else if (_first) + { + // Preambulo hasta el primer delimitador. + _first = false; + var skipper = new SectionStream(this); + var skip = new byte[16384]; + while (await skipper.ReadAsync(skip, ct) > 0) { } + } + + // Tras el delimitador: "--" es el final; si no, fin de linea y cabeceras de la parte. + if (!await EnsureAsync(2, ct)) + throw new InvalidDataException("Truncated multipart"); + if (_buffer[_start] == '-' && _buffer[_start + 1] == '-') + { + _finished = true; + return null; + } + await ReadLineAsync(ct); // resto de la linea del delimitador + + var section = new MultipartSection(); + while (true) + { + string line = await ReadLineAsync(ct); + if (line.Length == 0) + break; + int colon = line.IndexOf(':'); + if (colon > 0) + section.Headers[line[..colon].Trim()] = line[(colon + 1)..].Trim(); + } + if (section.Headers.TryGetValue("Content-Disposition", out var disposition)) + { + section.Name = DispositionParameter(disposition, "name") ?? ""; + section.FileName = DispositionParameter(disposition, "filename"); + } + _current = new SectionStream(this); + section.Body = _current; + return section; + } + + /// Lee un campo de texto pequeno (como mucho maxBytes). + public static async Task ReadFieldAsync(MultipartSection section, int maxBytes, CancellationToken ct) + { + var ms = new MemoryStream(); + var buffer = new byte[4096]; + int n; + while ((n = await section.Body.ReadAsync(buffer, ct)) > 0) + { + ms.Write(buffer, 0, n); + if (ms.Length > maxBytes) + throw new InvalidDataException("Field too large"); + } + return DecodeText(ms.ToArray()); + } + + /// UTF-8 si es valido; si no, Latin-1 (lo que envian las paginas clasicas). + internal static string DecodeText(byte[] bytes) + { + try + { + return new UTF8Encoding(false, true).GetString(bytes); + } + catch (DecoderFallbackException) + { + return Encoding.Latin1.GetString(bytes); + } + } + + internal static string? DispositionParameter(string header, string name) + { + int i = 0; + while (i < header.Length) + { + int semi = header.IndexOf(';', i); + if (semi < 0) + return null; + i = semi + 1; + while (i < header.Length && header[i] == ' ') + i++; + int eq = header.IndexOf('=', i); + if (eq < 0) + return null; + string key = header[i..eq].Trim(); + string value; + if (eq + 1 < header.Length && header[eq + 1] == '"') + { + // Valor entre comillas: puede contener ';'. IE antiguo no escapa las barras. + int close = header.IndexOf('"', eq + 2); + if (close < 0) + close = header.Length; + value = header[(eq + 2)..close]; + i = close + 1; + } + else + { + int next = header.IndexOf(';', eq); + value = (next < 0 ? header[(eq + 1)..] : header[(eq + 1)..next]).Trim(); + i = next < 0 ? header.Length : next; + } + if (key.Equals(name, StringComparison.OrdinalIgnoreCase)) + return value; + } + return null; + } + + private async Task ReadLineAsync(CancellationToken ct) + { + var bytes = new List(); + while (true) + { + if (_start >= _end && !await FillAsync(ct)) + throw new InvalidDataException("Truncated multipart headers"); + byte b = _buffer[_start++]; + if (b == '\n') + break; + bytes.Add(b); + if (bytes.Count > 8192) + throw new InvalidDataException("Multipart header too long"); + } + if (bytes.Count > 0 && bytes[^1] == '\r') + bytes.RemoveAt(bytes.Count - 1); + return DecodeText(bytes.ToArray()); + } + + private async Task EnsureAsync(int count, CancellationToken ct) + { + while (_end - _start < count) + if (!await FillAsync(ct)) + return false; + return true; + } + + /// Mueve lo pendiente al principio y lee mas. False si el flujo se acabo. + private async Task FillAsync(CancellationToken ct) + { + if (_eof) + return false; + if (_start > 0) + { + Buffer.BlockCopy(_buffer, _start, _buffer, 0, _end - _start); + _end -= _start; + _start = 0; + } + if (_end == _buffer.Length) + Array.Resize(ref _buffer, _buffer.Length * 2); + int n = await _stream.ReadAsync(_buffer.AsMemory(_end), ct); + if (n == 0) + { + _eof = true; + return false; + } + _end += n; + return true; + } + + /// Datos de una parte, hasta el delimitador. + private sealed class SectionStream(MultipartReader owner) : Stream + { + private bool _done; + + public override async ValueTask ReadAsync(Memory destination, CancellationToken ct = default) + { + var r = owner; + while (!_done) + { + var available = r._buffer.AsSpan(r._start, r._end - r._start); + int found = available.IndexOf(r._delimiter); + if (found == 0) + { + r._start += r._delimiter.Length; + _done = true; + return 0; + } + // Sin delimitador a la vista se puede entregar todo menos lo que podria ser su comienzo. + int safe = found > 0 ? found : available.Length - (r._delimiter.Length - 1); + if (safe > 0) + { + int n = Math.Min(safe, destination.Length); + available[..n].CopyTo(destination.Span); + r._start += n; + return n; + } + if (!await r.FillAsync(ct)) + throw new InvalidDataException("Truncated multipart body"); + } + return 0; + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) => + ReadAsync(buffer.AsMemory(offset, count), ct).AsTask(); + + public override int Read(byte[] buffer, int offset, int count) => + ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } +} diff --git a/src/Sunfish.Core/Web/Resources/file.gif b/src/Sunfish.Core/Web/Resources/file.gif new file mode 100644 index 0000000..fd251d7 --- /dev/null +++ b/src/Sunfish.Core/Web/Resources/file.gif Binary files differ diff --git a/src/Sunfish.Core/Web/Resources/folder.gif b/src/Sunfish.Core/Web/Resources/folder.gif new file mode 100644 index 0000000..23e5e9c --- /dev/null +++ b/src/Sunfish.Core/Web/Resources/folder.gif Binary files differ diff --git a/src/Sunfish.Core/Web/Resources/logo.gif b/src/Sunfish.Core/Web/Resources/logo.gif new file mode 100644 index 0000000..98f2d2c --- /dev/null +++ b/src/Sunfish.Core/Web/Resources/logo.gif Binary files differ diff --git a/src/Sunfish.Core/Web/Resources/sunfish.js b/src/Sunfish.Core/Web/Resources/sunfish.js new file mode 100644 index 0000000..357a2bd --- /dev/null +++ b/src/Sunfish.Core/Web/Resources/sunfish.js @@ -0,0 +1,93 @@ +// Sunfish: mejora de la subida en el modo moderno (solo se incluye en navegadores actuales). +// Sin este script el formulario funciona igual; con el, se puede arrastrar ficheros a la pagina, +// se ve el progreso y los ficheros grandes se envian por trozos con PUT + Content-Range. +(function () { + 'use strict'; + var form = document.getElementById('sf-upload'); + if (!form || !window.XMLHttpRequest || !window.File || !File.prototype.slice) return; + var input = form.querySelector('input[type=file]'); + var status = document.getElementById('sf-status'); + var uploadingText = form.getAttribute('data-uploading') || 'Uploading'; + var doneText = form.getAttribute('data-done') || 'Done'; + var CHUNK = 1024 * 1024; + var busy = false; + + function newId() { + var s = ''; + for (var i = 0; i < 24; i++) s += Math.floor(Math.random() * 16).toString(16); + return s; + } + + function show(text) { if (status) status.textContent = text; } + + function sendFile(file, progress, done) { + var id = newId(); + var url = location.pathname + encodeURIComponent(file.name); + + function part(offset) { + var end = Math.min(offset + CHUNK, file.size); + var xhr = new XMLHttpRequest(); + xhr.open('PUT', url); + xhr.setRequestHeader('X-Sunfish-Upload', id); + if (file.size > 0) + xhr.setRequestHeader('Content-Range', 'bytes ' + offset + '-' + (end - 1) + '/' + file.size); + xhr.upload.onprogress = function (e) { progress(offset + e.loaded); }; + xhr.onload = function () { + if (xhr.status >= 200 && xhr.status < 300) { + if (end >= file.size) done(null); else part(end); + } else { + done(file.name + ': ' + (xhr.responseText || xhr.status)); + } + }; + xhr.onerror = function () { done(file.name + ': network error'); }; + xhr.send(file.slice(offset, end)); + } + + part(0); + } + + function upload(fileList) { + if (busy || !fileList || !fileList.length) return; + busy = true; + var files = [], total = 0, sent = 0; + for (var i = 0; i < fileList.length; i++) { files.push(fileList[i]); total += fileList[i].size; } + + function next(k) { + if (k >= files.length) { + show(doneText); + location.reload(); + return; + } + var file = files[k]; + sendFile(file, function (bytes) { + var pct = total > 0 ? Math.floor((sent + bytes) * 100 / total) : 100; + show(uploadingText + ' ' + file.name + '... ' + pct + '%'); + }, function (error) { + if (error) { show(error); busy = false; return; } + sent += file.size; + next(k + 1); + }); + } + next(0); + } + + form.addEventListener('submit', function (e) { + if (!input || !input.files || !input.files.length) return; + e.preventDefault(); + upload(input.files); + }); + + var body = document.body; + body.addEventListener('dragover', function (e) { + e.preventDefault(); + body.className = 'sf-drop'; + }); + body.addEventListener('dragleave', function (e) { + if (e.target === body || !e.relatedTarget) body.className = ''; + }); + body.addEventListener('drop', function (e) { + e.preventDefault(); + body.className = ''; + if (e.dataTransfer) upload(e.dataTransfer.files); + }); +})(); diff --git a/src/Sunfish.Core/Web/Resources/up.gif b/src/Sunfish.Core/Web/Resources/up.gif new file mode 100644 index 0000000..fd6ae57 --- /dev/null +++ b/src/Sunfish.Core/Web/Resources/up.gif Binary files differ diff --git a/src/Sunfish.Core/Web/WebAdmin.cs b/src/Sunfish.Core/Web/WebAdmin.cs new file mode 100644 index 0000000..883dd3b --- /dev/null +++ b/src/Sunfish.Core/Web/WebAdmin.cs @@ -0,0 +1,453 @@ +using System.Globalization; +using System.Text; +using Sunfish.Auth; +using Sunfish.Config; +using Sunfish.Http; +using Sunfish.Log; +using Sunfish.Net; +using Sunfish.Qr; + +namespace Sunfish.Web; + +/// +/// Panel de administracion web en /$sunfish/admin/ (DOC/WEB.md#panel-de-administración): +/// desactivado por defecto; exige una IP de AllowFrom y el usuario "admin" con su contrasena +/// (HTTP Basic, con el mismo bloqueo por intentos que el resto). HTML clasico, formularios POST +/// con token anti-CSRF. Cambia lo mismo que la GUI salvo el propio HTTP (puerto, interfaces, +/// activarlo), que cortaria la conexion del panel: eso se cambia desde la aplicacion. +/// +public sealed partial class WebApp +{ + public const string AdminUser = "admin"; + private const string AdminBase = "/$sunfish/admin/"; + private const string AdminKey = "/$sunfish/admin"; + + private async Task AdminAsync(HttpContext ctx, List segments, ServerState st, ClientProfile profile) + { + var admin = st.Config.Admin; + var from = ctx.Request.RemoteEndPoint?.Address; + if (host is null || !admin.Enabled || admin.PasswordHash.Length == 0 || segments.Count > 3) + { + await SendErrorAsync(ctx, 404, st, profile); + return; + } + if (!AddressRules.Matches(admin.AllowFrom, from)) + { + log.Warning($"{from}: admin panel not allowed from this address"); + await SendErrorAsync(ctx, 403, st, profile); + return; + } + Authenticator.TryParseBasic(ctx.Request.Headers["Authorization"], out var user, out var password); + var result = _auth.Check([new UserConfig { Name = AdminUser, PasswordHash = admin.PasswordHash }], [AdminUser], + user.Length > 0 ? user : null, user.Length > 0 ? password : null, from); + if (result == AuthResult.Blocked) + { + ctx.Response.Headers["Retry-After"] = "60"; + await SendErrorAsync(ctx, 429, st, profile); + return; + } + if (result != AuthResult.Ok) + { + if (result == AuthResult.Wrong) + log.Warning($"{from}: admin panel auth failed for '{user}'"); + ctx.Response.Headers["WWW-Authenticate"] = $"Basic realm=\"{AsciiOnly(st.Config.Server.Name)} admin\", charset=\"UTF-8\""; + await SendErrorAsync(ctx, 401, st, profile); + return; + } + + string page = segments.Count == 3 ? segments[2] : ""; + bool classic = !profile.IsModern; + if (ctx.Request.Method == "POST" && page == "") + { + await AdminPostAsync(ctx, classic); + return; + } + if (ctx.Request.Method is not ("GET" or "HEAD")) + { + await SendErrorAsync(ctx, 405, st, profile); + return; + } + switch (page) + { + case "": + await SendAdminPageAsync(ctx, AdminMainPage(classic, ctx.Request.HasQuery("ok") ? Strings.AdminSaved : null, false)); + break; + case "share": + var config = host.Config; + string? name = ctx.Request.GetQuery("n"); + var share = name is null ? null : config.Shares.FirstOrDefault(s => s.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + if (name is not null && share is null) + { + await SendErrorAsync(ctx, 404, st, profile); + return; + } + await SendAdminPageAsync(ctx, AdminSharePage(classic, share ?? NewShare(config), share?.Name, null)); + break; + case "log": + await SendAdminPageAsync(ctx, AdminLogPage(classic)); + break; + case "qr" when ctx.Request.GetQuery("u") is { Length: > 0 and <= 300 } url && + (url.StartsWith("http://", StringComparison.Ordinal) || url.StartsWith("ftp://", StringComparison.Ordinal)): + ctx.Response.Headers["Cache-Control"] = "no-store"; + await ctx.Response.SendAsync(QrImage.Png(QrCode.Encode(url), 5), "image/png", ctx.Aborted); + break; + default: + await SendErrorAsync(ctx, 404, st, profile); + break; + } + } + + private static ShareConfig NewShare(SunfishConfig config) => + new() { Name = ShareSettings.SuggestName(config, "share"), Location = ShareSettings.SuggestLocation(config, "share") }; + + private static async Task SendAdminPageAsync(HttpContext ctx, HtmlBuilder html) + { + ctx.Response.Headers["Cache-Control"] = "no-store"; + await ctx.Response.SendAsync(html.ToBytes(), html.ContentType, ctx.Aborted); + } + + // ------------------------------------------------------------------ paginas + + private HtmlBuilder AdminStart(bool classic, string title, string? message, bool error) + { + var html = new HtmlBuilder(classic); + DirectoryPage.Header(html, title); + html.Raw(DirectoryPage.Font).Raw("

").Text(title).Raw("

\n") + .Raw("

").Text(Strings.AdminHome).Raw(" | ").Text(Strings.AdminNewShare).Raw(" | ") + .Text(Strings.AppLog).Raw(" | ").Text(Strings.Shares).Raw("

\n") + .Raw("

").Text(Strings.AdminCleartext).Raw("

\n"); + if (message is not null) + html.Raw("
") + .Raw(DirectoryPage.Font).Text(message).Raw(DirectoryPage.EndFont).Raw("
\n"); + return html; + } + + private static HtmlBuilder AdminEnd(HtmlBuilder html) + { + html.Raw(DirectoryPage.EndFont); + DirectoryPage.Footer(html); + return html; + } + + private void FormStart(HtmlBuilder html, string action) + { + html.Raw(""); + } + + private static void Hidden(HtmlBuilder html, string name, string value) => + html.Raw(""); + + private static void Field(HtmlBuilder html, string label, string name, string value, int size = 30, bool password = false) + { + html.Raw("").Raw(DirectoryPage.Font).Text(label).Raw(DirectoryPage.EndFont).Raw("\n"); + } + + private static void Check(HtmlBuilder html, string label, string name, bool value) => + html.Raw("").Raw(DirectoryPage.Font).Raw(" ").Text(label).Raw(DirectoryPage.EndFont).Raw("\n"); + + private static void Submit(HtmlBuilder html, string label) => + html.Raw("\n"); + + private HtmlBuilder AdminMainPage(bool classic, string? message, bool error) + { + var config = host!.Config; + var server = config.Server; + var html = AdminStart(classic, Strings.AdminTitle(server.Name), message, error); + + // Estado y direcciones + html.Raw("

").Text(Strings.AdminStatus).Raw("

\n
    \n
  • HTTP: ") + .Text(host.HttpRunning ? Strings.AdminOnPort(host.HttpPort) : Strings.AdminOff).Raw("\n
  • FTP: ") + .Text(host.FtpRunning ? Strings.AdminOnPort(host.FtpPort) : Strings.AdminOff).Raw("\n
\n"); + var addresses = LocalAddresses.Get(); + if (addresses.Count > 0 && host.HttpRunning) + { + html.Raw("

").Text(Strings.AppAddresses).Raw(":

\n
    \n"); + foreach (var a in addresses) + { + string url = LocalAddresses.HttpUrl(a.Address, host.HttpPort); + html.Raw("
  • ").Text(url).Raw(" (").Text(a.Interface).Raw(") [QR]\n"); + } + html.Raw("
\n"); + } + + // Servidor + html.Raw("

").Text(Strings.AdminServer).Raw("

\n"); + FormStart(html, "server"); + html.Raw("\n"); + Field(html, Strings.FieldName, "name", server.Name); + Check(html, Strings.AdminRootMenu, "rootmenu", server.RootMenu); + Check(html, Strings.AdminFtp, "ftp", server.Ftp); + Field(html, Strings.AdminFtpPort, "ftpport", server.FtpPort.ToString(CultureInfo.InvariantCulture), 6); + Submit(html, Strings.AdminSave); + html.Raw("
\n

").Text(Strings.AdminHttpFromApp).Raw("

\n"); + + // Shares + html.Raw("

").Text(Strings.Shares).Raw("

\n\n\n"); + foreach (var s in config.Shares) + { + html.Raw("\n"); + } + html.Raw("
") + .Text(Strings.FieldName).Raw("Location").Text(Strings.FieldPath).Raw("
").Text(s.Name).Raw(s.Enabled ? "" : " (" + HtmlBuilder.Escape(Strings.AdminDisabled, classic) + ")") + .Raw("").Text(s.Location).Raw("").Text(s.Path).Raw("[").Text(Strings.AdminEdit).Raw("] "); + FormStart(html, "share-del"); + Hidden(html, "n", s.Name); + html.Raw("
\n

").Text(Strings.AdminNewShare).Raw("

\n"); + + // Usuarios + html.Raw("

").Text(Strings.AdminUsers).Raw("

\n\n"); + foreach (var u in config.Users) + { + html.Raw("\n"); + } + html.Raw("
").Text(u.Name).Raw(""); + FormStart(html, "user-pass"); + Hidden(html, "name", u.Name); + html.Raw(DirectoryPage.Font).Text(Strings.FieldPassword).Raw(": ").Raw(DirectoryPage.EndFont) + .Raw(" "); + FormStart(html, "user-del"); + Hidden(html, "name", u.Name); + html.Raw("
\n"); + FormStart(html, "user-add"); + html.Raw("\n"); + Field(html, Strings.AdminNewUser, "name", "", 16); + Field(html, Strings.FieldPassword, "p1", "", 16, password: true); + Field(html, Strings.AdminRepeat, "p2", "", 16, password: true); + Submit(html, Strings.AdminAdd); + html.Raw("
\n"); + + // Panel + html.Raw("

").Text(Strings.AdminPanel).Raw("

\n"); + FormStart(html, "admin"); + html.Raw("\n"); + Field(html, Strings.AdminAllowFrom, "allow", string.Join(", ", config.Admin.AllowFrom), 40); + Field(html, Strings.AdminCurrentPassword, "cur", "", 16, password: true); + Field(html, Strings.AdminNewPassword, "p1", "", 16, password: true); + Field(html, Strings.AdminRepeat, "p2", "", 16, password: true); + Submit(html, Strings.AdminSave); + html.Raw("
\n

").Text(Strings.AdminAllowHelp).Raw("

\n"); + return AdminEnd(html); + } + + private HtmlBuilder AdminSharePage(bool classic, ShareConfig share, string? originalName, string? error) + { + var html = AdminStart(classic, originalName is null ? Strings.AdminNewShare : Strings.AdminEditShare(originalName), error, error is not null); + FormStart(html, "share-save"); + Hidden(html, "orig", originalName ?? ""); + html.Raw("\n"); + Field(html, Strings.FieldName, "name", share.Name); + Field(html, Strings.FieldLocation, "location", share.Location); + Field(html, Strings.FieldPath, "path", share.Path, 50); + Field(html, Strings.FieldIndex, "index", share.Index); + Check(html, Strings.OptEnabled, "enabled", share.Enabled); + Check(html, Strings.OptListing, "listing", share.Listing); + Check(html, Strings.OptUpload, "upload", share.Upload); + Check(html, Strings.OptOverwrite, "overwrite", share.Overwrite); + Check(html, Strings.OptRename, "rename", share.Rename); + Check(html, Strings.OptDelete, "delete", share.Delete); + Check(html, Strings.OptZip, "zip", share.Zip); + Check(html, "FTP", "ftp", share.Ftp); + Check(html, "WebDAV", "webdav", share.WebDav); + Check(html, Strings.AdminMac, "mac", share.Mac); + Check(html, Strings.AdminShortNames, "shortnames", share.ShortNames); + var users = host!.Config.Users; + if (users.Count > 0) + { + html.Raw("\n"); + } + Submit(html, Strings.AdminSave); + html.Raw("
").Raw(DirectoryPage.Font).Text(Strings.AdminAllowedUsers).Raw(DirectoryPage.EndFont).Raw(""); + foreach (var u in users) + html.Raw(DirectoryPage.Font).Raw(" ").Text(u.Name) + .Raw(DirectoryPage.EndFont).Raw("
\n"); + html.Raw("
\n"); + return AdminEnd(html); + } + + private HtmlBuilder AdminLogPage(bool classic) + { + var html = AdminStart(classic, Strings.AppLog, null, false); + html.Raw("
");
+        foreach (var e in host!.Log.Recent().Reverse().Take(300))
+            html.Text($"{e.Time:yyyy-MM-dd HH:mm:ss} {(e.Level >= LogLevel.Warning ? e.Level.ToString().ToUpperInvariant() + " " : "")}{e.Message}\n");
+        html.Raw("
\n"); + return AdminEnd(html); + } + + // ------------------------------------------------------------------ cambios + + private async Task AdminPostAsync(HttpContext ctx, bool classic) + { + var ms = new MemoryStream(); + var buffer = new byte[8192]; + int n; + while ((n = await ctx.Request.Body.ReadAsync(buffer, ctx.Aborted)) > 0) + { + ms.Write(buffer, 0, n); + if (ms.Length > MaxFormBytes) + throw new InvalidDataException("Form too large"); + } + var f = HttpRequest.ParseQuery(Encoding.Latin1.GetString(ms.ToArray())); + string action = f.GetValueOrDefault("a") ?? ""; + if (!_tokens.Validate(f.GetValueOrDefault("t"), action, AdminKey)) + { + await SendAdminPageAsync(ctx, AdminMainPage(classic, Strings.AdminExpired, true)); + return; + } + + var config = host!.Config.Clone(); + string Get(string key) => (f.GetValueOrDefault(key) ?? "").Trim(); + bool On(string key) => f.ContainsKey(key); + string? error = null; + + switch (action) + { + case "server": + config.Server.Name = Get("name").Length > 0 ? Get("name") : config.Server.Name; + config.Server.RootMenu = On("rootmenu"); + config.Server.Ftp = On("ftp"); + if (int.TryParse(Get("ftpport"), NumberStyles.None, CultureInfo.InvariantCulture, out int port) && port is >= 1024 and <= 65535) + config.Server.FtpPort = port; + else + error = Strings.AdminBadPort; + break; + + case "share-save": + { + string orig = Get("orig"); + var existing = orig.Length > 0 ? config.Shares.FirstOrDefault(s => s.Name.Equals(orig, StringComparison.OrdinalIgnoreCase)) : null; + var share = existing ?? new ShareConfig(); + var edited = new ShareConfig + { + Name = Get("name"), Location = Get("location"), Path = Get("path"), Index = Get("index"), + Enabled = On("enabled"), Listing = On("listing"), Upload = On("upload"), Overwrite = On("overwrite"), + Rename = On("rename"), Delete = On("delete"), Zip = On("zip"), Ftp = On("ftp"), WebDav = On("webdav"), + Mac = On("mac"), ShortNames = On("shortnames"), + Users = config.Users.Where(u => On("u." + u.Name)).Select(u => u.Name).ToList(), + }; + if (ShareSettings.Validate(config, edited, existing?.Name) is { } problem) + { + await SendAdminPageAsync(ctx, AdminSharePage(classic, edited, existing?.Name, problem)); + return; + } + if (existing is null) + config.Shares.Add(edited); + else + config.Shares[config.Shares.IndexOf(share)] = edited; + break; + } + + case "share-del": + if (config.Shares.RemoveAll(s => s.Name.Equals(Get("n"), StringComparison.OrdinalIgnoreCase)) == 0) + error = Strings.ErrorBadName(Get("n")); + break; + + case "user-add": + { + string name = Get("name"); + if (!ShareSettings.IsValidName(name) || name.Equals(AdminUser, StringComparison.OrdinalIgnoreCase)) + error = Strings.ErrorBadName(name); + else if (config.Users.Any(u => u.Name.Equals(name, StringComparison.OrdinalIgnoreCase))) + error = Strings.ErrorNameInUse(name); + else if (CheckNewPassword(f) is { } bad) + error = bad; + else + config.Users.Add(new UserConfig { Name = name, PasswordHash = Passwords.Hash(f["p1"]) }); + break; + } + + case "user-pass": + { + var user = config.Users.FirstOrDefault(u => u.Name == Get("name")); + if (user is null) + error = Strings.ErrorBadName(Get("name")); + else if (CheckNewPassword(f) is { } bad) + error = bad; + else + user.PasswordHash = Passwords.Hash(f["p1"]); + break; + } + + case "user-del": + { + string name = Get("name"); + if (config.Users.RemoveAll(u => u.Name == name) == 0) + error = Strings.ErrorBadName(name); + foreach (var s in config.Shares) + s.Users.RemoveAll(u => u.Equals(name, StringComparison.OrdinalIgnoreCase)); + break; + } + + case "admin": + { + // Cambiar algo del panel exige la contrasena actual. + if (!Passwords.Verify(f.GetValueOrDefault("cur") ?? "", config.Admin.PasswordHash)) + { + error = Strings.AdminWrongCurrent; + break; + } + var rules = Get("allow").Split([',', ' ', ';'], StringSplitOptions.RemoveEmptyEntries).ToList(); + var invalid = rules.FirstOrDefault(r => !AddressRules.IsValid(r)); + if (rules.Count == 0 || invalid is not null) + { + error = Strings.AdminBadAllow(invalid ?? ""); + break; + } + if (!AddressRules.Matches(rules, ctx.Request.RemoteEndPoint?.Address)) + { + error = Strings.AdminLockout; // no dejar fuera a quien lo esta cambiando + break; + } + config.Admin.AllowFrom = rules; + if ((f.GetValueOrDefault("p1") ?? "").Length > 0) + { + if (CheckNewPassword(f) is { } bad) + { + error = bad; + break; + } + config.Admin.PasswordHash = Passwords.Hash(f["p1"]); + } + break; + } + + default: + error = Strings.AdminExpired; + break; + } + + if (error is null) + { + if (!await host.SaveAndApplyAsync(config)) + error = Strings.AdminApplyFailed; + else + log.Info($"{ctx.Request.RemoteEndPoint?.Address} admin {action}"); + } + if (error is not null) + { + await SendAdminPageAsync(ctx, AdminMainPage(classic, error, true)); + return; + } + string location = BaseUrl(ctx.Request) + AdminBase + "?ok"; + ctx.Response.StatusCode = ctx.Request.IsHttp11OrLater ? 303 : 302; + ctx.Response.Headers["Location"] = location; + await ctx.Response.SendAsync(Encoding.ASCII.GetBytes($"{location}"), "text/html", ctx.Aborted); + } + + private static string? CheckNewPassword(IReadOnlyDictionary f) + { + string p1 = f.GetValueOrDefault("p1") ?? "", p2 = f.GetValueOrDefault("p2") ?? ""; + if (p1.Length == 0) + return Strings.ErrorPasswordEmpty; + return p1 == p2 ? null : Strings.AdminPasswordMismatch; + } +} diff --git a/src/Sunfish.Core/Web/WebApp.cs b/src/Sunfish.Core/Web/WebApp.cs new file mode 100644 index 0000000..24ac5a2 --- /dev/null +++ b/src/Sunfish.Core/Web/WebApp.cs @@ -0,0 +1,753 @@ +using System.Globalization; +using System.Net.Sockets; +using System.Text; +using System.Text.RegularExpressions; +using Sunfish.Auth; +using Sunfish.Config; +using Sunfish.Http; +using Sunfish.Log; +using Sunfish.Shares; + +namespace Sunfish.Web; + +/// Configuracion vigente para atender peticiones; se sustituye entera al recargar. +public sealed class ServerState(SunfishConfig config) +{ + public SunfishConfig Config { get; } = config; + public ShareTree Tree { get; } = new(config.Shares); +} + +/// +/// La web: recursos internos, autodescarga y los shares (indice, navegador de directorios, +/// descargas, ZIP, subidas y cambios). Los cambios llegan por formularios POST con token +/// anti-CSRF; la subida moderna usa PUT por trozos (un PUT de otra web necesitaria permiso CORS, +/// que nunca se da). Los shares con Users exigen HTTP Basic. +/// +public sealed partial class WebApp(Func state, ServerLog log, Authenticator auth, Host.SunfishHost? host = null) +{ + private const string ListParameter = "$list"; + private const int MaxFormBytes = 65536; + + private readonly Authenticator _auth = auth; + private readonly CsrfTokens _tokens = new(); + + /// Contexto de una peticion ya resuelta contra un share. + private sealed record ShareRequest(HttpContext Ctx, ServerState State, ClientProfile Profile, + List Segments, ShareConfig Share, IReadOnlyList VirtualChildren) + { + /// Ruta de la carpeta pedida ("/fotos/2026"); identifica los tokens anti-CSRF. + public string FolderKey => "/" + string.Join('/', Segments); + + /// Ruta para el registro de un elemento de la carpeta. + public string ItemPath(string name) => "/" + string.Join('/', Segments.Append(name)); + + /// Ruta pedida tal cual (PUT apunta al fichero, no a la carpeta). + public string RequestPath => FolderKey; + } + + public async Task HandleAsync(HttpContext ctx) + { + try + { + await RouteAsync(ctx, state()); + } + finally + { + var r = ctx.Request; + log.Info($"{r.RemoteEndPoint?.Address} {r.Method} {r.Path}{(r.QueryString.Length > 0 ? "?" + r.QueryString : "")} {ctx.Response.StatusCode} {ctx.Response.BytesSent}"); + } + } + + private async Task RouteAsync(HttpContext ctx, ServerState st) + { + var req = ctx.Request; + var profile = ClientProfile.Detect(req.Headers["User-Agent"], req.GetQuery("p")); + + bool dav = IsDavMethod(req.Method); + if (!dav && req.Method is not ("GET" or "HEAD" or "POST" or "PUT" or "OPTIONS")) + { + ctx.Response.Headers["Allow"] = AllowedMethods; + await SendErrorAsync(ctx, 405, st, profile); + return; + } + if (req.Method == "OPTIONS") + { + await DavOptionsAsync(ctx); + return; + } + if (!PathRules.TrySplitUrlPath(req.Path, out var segments, out bool trailingSlash)) + { + await SendErrorAsync(ctx, 400, st, profile); + return; + } + + if (segments.Count > 0 && segments[0].Equals(LocationPath.Reserved, StringComparison.OrdinalIgnoreCase)) + { + await InternalAsync(ctx, segments, st, profile); + return; + } + + var resolution = st.Tree.Resolve(segments); + if (dav) + { + await DavAsync(ctx, st, profile, segments, resolution); + return; + } + if (resolution.Share is { } share) + { + if (!await AuthorizeAsync(ctx, st, profile, share)) + return; + var sr = new ShareRequest(ctx, st, profile, segments, share, resolution.VirtualChildren); + string? physical = ShareFileSystem.TryGetPhysicalPath(share, resolution.Relative, aliases: share.ShortNames && req.Method != "PUT"); + + if (req.Method == "PUT") + { + await PutAsync(sr, physical, trailingSlash); + return; + } + if (physical is not null && Directory.Exists(physical)) + { + if (!trailingSlash) + { + await RedirectToFolderAsync(ctx); + return; + } + if (req.Method == "POST") + { + await PostAsync(sr, physical); + return; + } + await FolderGetAsync(sr, physical); + return; + } + if (physical is not null && File.Exists(physical) && !trailingSlash && req.Method != "POST") + { + await SendShareFileAsync(sr, physical); + return; + } + } + else if (segments.Count == 0 && st.Config.Server.RootMenu && req.Method is "GET" or "HEAD") + { + await SendListingAsync(ctx, st, profile, segments, null, null, resolution.VirtualChildren, new FolderActions()); + return; + } + + // Carpeta intermedia de una location mas especifica (sin carpeta real que la respalde). + if (resolution.VirtualChildren.Count > 0 && segments.Count > 0 && req.Method is "GET" or "HEAD") + { + if (!trailingSlash) + await RedirectToFolderAsync(ctx); + else + await SendListingAsync(ctx, st, profile, segments, null, null, resolution.VirtualChildren, new FolderActions()); + return; + } + + if (segments.Count == 1 && segments[0].Equals("favicon.ico", StringComparison.OrdinalIgnoreCase)) + { + await SendResourceAsync(ctx, "favicon.ico"); + return; + } + await SendErrorAsync(ctx, req.Method is "POST" or "PUT" ? 403 : 404, st, profile); + } + + // ------------------------------------------------------------------ autenticacion + + private async Task AuthorizeAsync(HttpContext ctx, ServerState st, ClientProfile profile, ShareConfig share) + { + if (share.Users.Count == 0) + return true; + Authenticator.TryParseBasic(ctx.Request.Headers["Authorization"], out var user, out var password); + var result = _auth.Check(st.Config.Users, share.Users, + user.Length > 0 ? user : null, user.Length > 0 ? password : null, ctx.Request.RemoteEndPoint?.Address); + if (result == AuthResult.Ok) + return true; + if (result == AuthResult.Blocked) + { + ctx.Response.Headers["Retry-After"] = "60"; + await SendErrorAsync(ctx, 429, st, profile); + return false; + } + if (result == AuthResult.Wrong) + log.Warning($"{ctx.Request.RemoteEndPoint?.Address}: auth failed for '{user}' ({share.Name})"); + ctx.Response.Headers["WWW-Authenticate"] = $"Basic realm=\"{AsciiOnly(st.Config.Server.Name)}\", charset=\"UTF-8\""; + await SendErrorAsync(ctx, 401, st, profile); + return false; + } + + private static string AsciiOnly(string text) + { + var sb = new StringBuilder(); + foreach (char c in text) + sb.Append(c is >= ' ' and < (char)0x7F and not ('"' or '\\') ? c : '_'); + return sb.ToString(); + } + + // ------------------------------------------------------------------ GET de carpetas + + private async Task FolderGetAsync(ShareRequest sr, string physical) + { + var (ctx, st, profile, share) = (sr.Ctx, sr.State, sr.Profile, sr.Share); + var req = ctx.Request; + var actions = ActionsFor(sr); + + if (req.GetQuery("$rename") is { } toRename) + { + await ConfirmPageAsync(sr, physical, toRename, share.Rename, isDelete: false, actions); + return; + } + if (req.GetQuery("$delete") is { } toDelete) + { + await ConfirmPageAsync(sr, physical, toDelete, share.Delete, isDelete: true, actions); + return; + } + if (req.HasQuery("$zip")) + { + if (!share.Zip) + await SendErrorAsync(ctx, 403, st, profile); + else + await SendZipAsync(sr, physical); + return; + } + if (!req.HasQuery(ListParameter) && share.Index.Length > 0) + { + string index = Path.Combine(physical, share.Index); + if (File.Exists(index)) + { + await SendFileAsync(ctx, index); + return; + } + } + if (!share.Listing) + { + await SendErrorAsync(ctx, 403, st, profile); + return; + } + await SendListingAsync(ctx, st, profile, sr.Segments, share, physical, sr.VirtualChildren, actions); + } + + private FolderActions ActionsFor(ShareRequest sr) + { + string folder = sr.FolderKey; + return new FolderActions + { + Upload = sr.Share.Upload, + Rename = sr.Share.Rename, + Delete = sr.Share.Delete, + Zip = sr.Share.Zip, + Token = action => _tokens.Create(action, folder), + }; + } + + private async Task ConfirmPageAsync(ShareRequest sr, string physical, string name, bool allowed, bool isDelete, FolderActions actions) + { + var (ctx, st, profile) = (sr.Ctx, sr.State, sr.Profile); + if (!allowed) + { + await SendErrorAsync(ctx, 403, st, profile); + return; + } + string? target = ExistingChild(sr, physical, name); + if (target is null) + { + await SendErrorAsync(ctx, 404, st, profile); + return; + } + var model = new DirectoryPage.Model { ServerName = st.Config.Server.Name, Segments = sr.Segments, Profile = profile, Actions = actions }; + var html = isDelete ? DirectoryPage.RenderDelete(model, name, Directory.Exists(target)) : DirectoryPage.RenderRename(model, name); + ctx.Response.Headers["Cache-Control"] = "no-cache"; + await ctx.Response.SendAsync(html.ToBytes(), html.ContentType, ctx.Aborted); + } + + /// Ruta real de un elemento de la carpeta que exista y no este tapado por una location. + private static string? ExistingChild(ShareRequest sr, string physicalDirectory, string name) + { + if (!PathRules.IsValidSegment(name) || sr.VirtualChildren.Contains(name, StringComparer.OrdinalIgnoreCase)) + return null; + string? path = ShareFileSystem.TryGetPhysicalPath(sr.Share, [.. RelativeOf(sr), name], aliases: sr.Share.ShortNames); + return path is not null && (File.Exists(path) || Directory.Exists(path)) ? path : null; + } + + private static IReadOnlyList RelativeOf(ShareRequest sr) => + sr.Segments.Skip(LocationPath.Segments(sr.Share.Location).Length).ToList(); + + // ------------------------------------------------------------------ POST: formularios + + private async Task PostAsync(ShareRequest sr, string physical) + { + var (ctx, st, profile) = (sr.Ctx, sr.State, sr.Profile); + string? contentType = ctx.Request.Headers["Content-Type"]; + try + { + if (MultipartReader.GetBoundary(contentType) is { } boundary) + await PostMultipartAsync(sr, physical, boundary); + else if (contentType?.StartsWith("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase) == true) + await PostFormAsync(sr, physical); + else + await SendErrorAsync(ctx, 400, st, profile); + } + catch (InvalidDataException) + { + if (!ctx.Response.HeadersSent) + await SendErrorAsync(ctx, 400, st, profile); + } + } + + /// Subida por formulario (RFC 1867): campos a y t primero, despues los ficheros. + private async Task PostMultipartAsync(ShareRequest sr, string physical, string boundary) + { + var (ctx, st, profile, share) = (sr.Ctx, sr.State, sr.Profile, sr.Share); + var reader = new MultipartReader(ctx.Request.Body, boundary); + var fields = new Dictionary(StringComparer.Ordinal); + var errors = new List(); + int saved = 0; + + while (await reader.NextAsync(ctx.Aborted) is { } section) + { + if (section.FileName is null) + { + fields[section.Name] = await MultipartReader.ReadFieldAsync(section, MaxFormBytes, ctx.Aborted); + continue; + } + // Ningun fichero toca el disco sin accion, token y permiso. + if (fields.GetValueOrDefault("a") != "upload" || !_tokens.Validate(fields.GetValueOrDefault("t"), "upload", sr.FolderKey)) + { + await SendErrorAsync(ctx, 403, st, profile, Strings.BadToken); + return; + } + if (!share.Upload) + { + await SendErrorAsync(ctx, 403, st, profile); + return; + } + string name = FileOperations.CleanUploadName(section.FileName); + if (name.Length == 0) + continue; // campo de fichero sin fichero elegido + var result = await FileOperations.SaveAsync(physical, name, section.Body, share.Overwrite, ctx.Aborted); + if (result == FileOpResult.Ok) + { + saved++; + string finalName = await UnpackMacAsync(sr, Path.Combine(physical, name)); + log.Info($"{ctx.Request.RemoteEndPoint?.Address} upload {sr.ItemPath(finalName)}"); + } + else + { + errors.Add(Strings.OpResult(result, name)); + } + } + if (saved == 0 && errors.Count == 0 && fields.GetValueOrDefault("a") is { } action && action != "upload") + { + // Navegador que envia un formulario sin ficheros como multipart. + await ApplyFormAsync(sr, physical, fields); + return; + } + if (errors.Count > 0) + await SendErrorAsync(ctx, 409, st, profile, string.Join(" ", errors)); + else + await RedirectAfterPostAsync(sr); + } + + private async Task PostFormAsync(ShareRequest sr, string physical) + { + var ms = new MemoryStream(); + var buffer = new byte[8192]; + int n; + while ((n = await sr.Ctx.Request.Body.ReadAsync(buffer, sr.Ctx.Aborted)) > 0) + { + ms.Write(buffer, 0, n); + if (ms.Length > MaxFormBytes) + throw new InvalidDataException("Form too large"); + } + // Las paginas clasicas son ISO-8859-1 y los navegadores envian en ese juego; ParseQuery + // prueba UTF-8 y si no, Latin-1. + var fields = HttpRequest.ParseQuery(Encoding.Latin1.GetString(ms.ToArray())); + await ApplyFormAsync(sr, physical, fields); + } + + private async Task ApplyFormAsync(ShareRequest sr, string physical, IReadOnlyDictionary fields) + { + var (ctx, st, profile, share) = (sr.Ctx, sr.State, sr.Profile, sr.Share); + string action = fields.GetValueOrDefault("a") ?? ""; + if (!_tokens.Validate(fields.GetValueOrDefault("t"), action, sr.FolderKey)) + { + await SendErrorAsync(ctx, 403, st, profile, Strings.BadToken); + return; + } + string name = (fields.GetValueOrDefault("name") ?? "").Trim(); + FileOpResult result; + switch (action) + { + case "mkdir" when share.Upload: + result = sr.VirtualChildren.Contains(name, StringComparer.OrdinalIgnoreCase) + ? FileOpResult.Exists + : FileOperations.CreateFolder(physical, name); + break; + case "rename" when share.Rename: + { + string to = (fields.GetValueOrDefault("to") ?? "").Trim(); + string? source = ExistingChild(sr, physical, name); + result = source is null ? FileOpResult.NotFound + : sr.VirtualChildren.Contains(to, StringComparer.OrdinalIgnoreCase) ? FileOpResult.Exists + : FileOperations.Rename(source, to); + if (result != FileOpResult.Ok) + name = result == FileOpResult.NotFound ? name : to; + break; + } + case "delete" when share.Delete: + { + string? target = ExistingChild(sr, physical, name); + result = target is null ? FileOpResult.NotFound : FileOperations.Delete(target); + break; + } + default: + await SendErrorAsync(ctx, 403, st, profile); + return; + } + if (result != FileOpResult.Ok) + { + await SendErrorAsync(ctx, result == FileOpResult.NotFound ? 404 : 409, st, profile, Strings.OpResult(result, name)); + return; + } + log.Info($"{ctx.Request.RemoteEndPoint?.Address} {action} {sr.ItemPath(name)}"); + await RedirectAfterPostAsync(sr); + } + + /// Vuelta a la carpeta tras un POST: 303 en HTTP/1.1, 302 en 1.0 (no conoce el 303). + private static async Task RedirectAfterPostAsync(ShareRequest sr) + { + var ctx = sr.Ctx; + string location = BaseUrl(ctx.Request) + DirectoryPage.UrlOf(sr.Segments) + DirectoryPage.ProfileQuery(sr.Profile, true); + ctx.Response.StatusCode = ctx.Request.IsHttp11OrLater ? 303 : 302; + ctx.Response.Headers["Location"] = location; + string html = $"{location}"; + await ctx.Response.SendAsync(Encoding.ASCII.GetBytes(html), "text/html", ctx.Aborted); + } + + // ------------------------------------------------------------------ PUT: subida moderna + + [GeneratedRegex("^[A-Za-z0-9]{8,64}$")] + private static partial Regex UploadIdPattern(); + + /// + /// PUT sobre la URL del fichero. Con Content-Range y X-Sunfish-Upload se sube por trozos + /// (los trozos deben llegar en orden); sin ellos, el cuerpo es el fichero entero. + /// + private async Task PutAsync(ShareRequest sr, string? physical, bool trailingSlash) + { + var (ctx, st, profile, share) = (sr.Ctx, sr.State, sr.Profile, sr.Share); + var req = ctx.Request; + var relative = RelativeOf(sr); + if (trailingSlash || relative.Count == 0 || physical is null) + { + await SendTextAsync(ctx, 400, "Bad target"); + return; + } + string name = relative[^1]; + string? directory = Path.GetDirectoryName(physical); + if (directory is null || !Directory.Exists(directory) || sr.VirtualChildren.Count > 0 && relative.Count == 0) + { + await SendTextAsync(ctx, 409, Strings.OpResult(FileOpResult.NotFound, name)); // 409: falta la carpeta (WebDAV) + return; + } + bool exists = File.Exists(physical); + // Los clientes WebDAV (Explorador, Finder) crean primero un fichero vacio y despues lo + // rellenan con otro PUT: sustituir un fichero vacio no cuenta como sobrescribir. + bool replaceable = share.Overwrite || (exists && new FileInfo(physical).Length == 0); + if (!share.Upload || (exists && !replaceable)) + { + await SendTextAsync(ctx, exists ? 409 : 403, exists ? Strings.OpResult(FileOpResult.Exists, name) : Strings.ErrorText(403)); + return; + } + // Finder escribe ._nombre y .DS_Store por WebDAV; se guardan (ocultos en los listados). + bool macMetadata = share.WebDav && FileOperations.IsMacMetadataName(name); + + string? range = req.Headers["Content-Range"]; + string? id = req.Headers["X-Sunfish-Upload"]; + long? declared = long.TryParse(req.Headers["Content-Length"], NumberStyles.None, CultureInfo.InvariantCulture, out long cl) ? cl : null; + + if (range is null) + { + if (declared is long size && !FileOperations.HasSpace(directory, size)) + { + await SendTextAsync(ctx, 507, Strings.OpResult(FileOpResult.NoSpace, name)); + return; + } + var whole = await FileOperations.SaveAsync(directory, name, req.Body, replaceable, ctx.Aborted, macMetadata); + // Solo las subidas de la pagina (X-Sunfish-Upload) se decodifican; un PUT de WebDAV + // guarda los bytes tal cual (es un gestor de ficheros copiando un fichero). + if (whole == FileOpResult.Ok && id is not null) + await UnpackMacAsync(sr, Path.Combine(directory, name)); + await PutResultAsync(sr, whole, name, completed: true, exists); + return; + } + + if (id is null || !UploadIdPattern().IsMatch(id) || !TryParseContentRange(range, out long start, out long end, out long total)) + { + await SendTextAsync(ctx, 400, "Bad Content-Range or X-Sunfish-Upload"); + return; + } + if (declared is long partLength && partLength != end - start + 1) + { + await SendTextAsync(ctx, 400, "Content-Length does not match Content-Range"); + return; + } + if (start == 0 && !FileOperations.HasSpace(directory, total)) + { + await SendTextAsync(ctx, 507, Strings.OpResult(FileOpResult.NoSpace, name)); + return; + } + var (result, completed) = await FileOperations.WritePartAsync(directory, name, id, start, total, req.Body, replaceable, ctx.Aborted); + if (result == FileOpResult.Ok && completed) + await UnpackMacAsync(sr, Path.Combine(directory, name)); + await PutResultAsync(sr, result, name, completed, exists); + } + + private async Task PutResultAsync(ShareRequest sr, FileOpResult result, string name, bool completed, bool existed) + { + var ctx = sr.Ctx; + if (result != FileOpResult.Ok) + { + int status = result switch + { + FileOpResult.InvalidName => 400, + FileOpResult.NoSpace => 507, + _ => 409, + }; + await SendTextAsync(ctx, status, Strings.OpResult(result, name)); + return; + } + if (completed) + log.Info($"{ctx.Request.RemoteEndPoint?.Address} upload {sr.RequestPath}"); + ctx.Response.StatusCode = completed ? (existed ? 204 : 201) : 202; + await ctx.Response.StartAsync(0, ctx.Aborted); + } + + /// "bytes 0-1048575/5000000". + internal static bool TryParseContentRange(string header, out long start, out long end, out long total) + { + start = end = total = 0; + header = header.Trim(); + if (!header.StartsWith("bytes ", StringComparison.OrdinalIgnoreCase)) + return false; + var spec = header[6..].Split('/'); + if (spec.Length != 2) + return false; + var bounds = spec[0].Split('-'); + return bounds.Length == 2 && + long.TryParse(bounds[0], NumberStyles.None, CultureInfo.InvariantCulture, out start) && + long.TryParse(bounds[1], NumberStyles.None, CultureInfo.InvariantCulture, out end) && + long.TryParse(spec[1], NumberStyles.None, CultureInfo.InvariantCulture, out total) && + start <= end && end < total; + } + + // ------------------------------------------------------------------ ZIP + + private async Task SendZipAsync(ShareRequest sr, string physical) + { + var (ctx, st, profile) = (sr.Ctx, sr.State, sr.Profile); + var entries = ZipStreamWriter.Collect(physical) + .Where(e => !sr.VirtualChildren.Contains(e.Name.Split('/')[0], StringComparer.OrdinalIgnoreCase)) + .ToList(); + var zip = new ZipStreamWriter(entries, preferUtf8: profile.IsModern); + if (zip.TooLarge) + { + await SendErrorAsync(ctx, 413, st, profile, Strings.ZipTooLarge); + return; + } + string folderName = sr.Segments.Count > 0 ? sr.Segments[^1] : st.Config.Server.Name; + ctx.Response.Headers["Content-Type"] = "application/zip"; + ctx.Response.Headers["Content-Disposition"] = ContentDisposition(folderName + ".zip"); + var body = await ctx.Response.StartAsync(zip.TotalLength, ctx.Aborted); + if (!ctx.Request.IsHead) + await zip.WriteAsync(body, ctx.Aborted); + } + + // ------------------------------------------------------------------ respuestas comunes + + private async Task InternalAsync(HttpContext ctx, List segments, ServerState st, ClientProfile profile) + { + if (segments.Count >= 2 && segments[1] == "admin") + { + await AdminAsync(ctx, segments, st, profile); + return; + } + if (ctx.Request.Method is "GET" or "HEAD") + { + if (segments.Count == 3 && segments[1] == "res") + { + await SendResourceAsync(ctx, segments[2]); + return; + } + if (segments.Count == 2 && segments[1] == "download" && st.Config.Server.SelfDownload && + Environment.ProcessPath is { } exe && File.Exists(exe)) + { + ctx.Response.Headers["Content-Disposition"] = ContentDisposition(Path.GetFileName(exe)); + await SendFileAsync(ctx, exe, MimeTypes.Default); + return; + } + } + await SendErrorAsync(ctx, 404, st, profile); + } + + private static async Task SendListingAsync(HttpContext ctx, ServerState st, ClientProfile profile, + IReadOnlyList segments, ShareConfig? share, string? physical, IReadOnlyList virtualChildren, FolderActions actions) + { + var items = new List(); + foreach (var child in virtualChildren) + items.Add(new ListingItem(new FileEntry(child, true, 0, default, IsVirtual: true), false)); + if (physical is not null && share is not null) + { + // DOS y Windows 3.1 ven alias 8.3; se calculan con todos los nombres de la carpeta + // (tambien los ocultos), igual que al resolverlos. + var aliases = profile.UsesShortNames && share.ShortNames + ? ShortNames.Map(new DirectoryInfo(physical).EnumerateFileSystemInfos().Select(i => i.Name)) + : null; + foreach (var entry in ShareFileSystem.List(physical)) + { + // Una location mas especifica tapa a la carpeta real del mismo nombre. + if (virtualChildren.Contains(entry.Name, StringComparer.OrdinalIgnoreCase)) + continue; + bool hasIndex = entry.IsDirectory && share.Index.Length > 0 && + File.Exists(Path.Combine(physical, entry.Name, share.Index)); + bool macFork = share.Mac && !entry.IsDirectory && Mac.MacFiles.HasCompanion(Path.Combine(physical, entry.Name)); + items.Add(new ListingItem(entry, hasIndex, aliases?.GetValueOrDefault(entry.Name), macFork)); + } + } + string sort = ctx.Request.GetQuery("sort") is "s" or "d" ? ctx.Request.GetQuery("sort")! : "n"; + var html = DirectoryPage.Render(new DirectoryPage.Model + { + ServerName = st.Config.Server.Name, + Segments = segments, + Items = items, + Profile = profile, + Sort = sort, + Actions = actions, + }); + ctx.Response.Headers["Cache-Control"] = "no-cache"; + await ctx.Response.SendAsync(html.ToBytes(), html.ContentType, ctx.Aborted); + } + + private static async Task SendFileAsync(HttpContext ctx, string physical, string? contentType = null) + { + var req = ctx.Request; + var res = ctx.Response; + var info = new FileInfo(physical); + DateTime modified = TruncateToSeconds(info.LastWriteTimeUtc); + + if (req.HasQuery("dl")) + res.Headers["Content-Disposition"] = ContentDisposition(info.Name); + res.Headers["Content-Type"] = contentType ?? MimeTypes.For(info.Name); + res.Headers["Last-Modified"] = HttpResponse.FormatDate(modified); + res.Headers["Accept-Ranges"] = "bytes"; + + if (TryParseHttpDate(req.Headers["If-Modified-Since"], out var since) && modified <= since) + { + res.StatusCode = 304; + await res.StartAsync(null, ctx.Aborted); + return; + } + + long start = 0, length = info.Length; + switch (ByteRange.TryParse(req.Headers["Range"], info.Length, out var range)) + { + case ByteRange.ParseResult.Valid: + res.StatusCode = 206; + res.Headers["Content-Range"] = $"bytes {range.Start}-{range.End}/{info.Length}"; + start = range.Start; + length = range.Length; + break; + case ByteRange.ParseResult.Unsatisfiable: + res.StatusCode = 416; + res.Headers["Content-Range"] = $"bytes */{info.Length}"; + res.Headers["Content-Type"] = null; + await res.StartAsync(0, ctx.Aborted); + return; + } + + await using var file = new FileStream(physical, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, + bufferSize: 1, useAsync: true); + var body = await res.StartAsync(length, ctx.Aborted); + if (req.IsHead) + return; + file.Position = start; + var buffer = new byte[65536]; + long remaining = length; + while (remaining > 0) + { + int n = await file.ReadAsync(buffer.AsMemory(0, (int)Math.Min(buffer.Length, remaining)), ctx.Aborted); + if (n == 0) + break; + await body.WriteAsync(buffer.AsMemory(0, n), ctx.Aborted); + remaining -= n; + } + if (remaining > 0) + throw new IOException("File shrank while sending"); // la conexion se cierra + } + + private static async Task SendResourceAsync(HttpContext ctx, string name) + { + var data = WebResources.Get(name); + if (data is null) + { + await SendTextAsync(ctx, 404, "404 Not Found"); + return; + } + ctx.Response.Headers["Cache-Control"] = "max-age=86400"; + await ctx.Response.SendAsync(data, MimeTypes.For(name), ctx.Aborted); + } + + private static async Task SendTextAsync(HttpContext ctx, int status, string text) + { + ctx.Response.StatusCode = status; + await ctx.Response.SendAsync(Encoding.UTF8.GetBytes(text), "text/plain; charset=utf-8", ctx.Aborted); + } + + private static async Task SendErrorAsync(HttpContext ctx, int status, ServerState st, ClientProfile profile, string? detail = null) + { + ctx.Response.StatusCode = status; + var html = DirectoryPage.RenderError(status, st.Config.Server.Name, profile, detail); + await ctx.Response.SendAsync(html.ToBytes(), html.ContentType, ctx.Aborted); + } + + /// Carpeta pedida sin barra final: redireccion con URL absoluta (la exige HTTP/1.0). + private static async Task RedirectToFolderAsync(HttpContext ctx) + { + var req = ctx.Request; + string location = BaseUrl(req) + req.Path + "/" + (req.QueryString.Length > 0 ? "?" + req.QueryString : ""); + ctx.Response.StatusCode = 301; + ctx.Response.Headers["Location"] = location; + string html = $"{location}"; + await ctx.Response.SendAsync(Encoding.ASCII.GetBytes(html), "text/html", ctx.Aborted); + } + + internal static string BaseUrl(HttpRequest req) + { + string? host = req.Headers["Host"]; + if (string.IsNullOrWhiteSpace(host) && req.LocalEndPoint is { } local) + { + host = local.Address.AddressFamily == AddressFamily.InterNetworkV6 + ? $"[{local.Address}]:{local.Port}" + : $"{local.Address}:{local.Port}"; + } + return "http://" + (host ?? "localhost"); + } + + /// attachment con nombre ASCII (navegadores antiguos) y UTF-8 (RFC 5987). + internal static string ContentDisposition(string name) => + $"attachment; filename=\"{AsciiOnly(name)}\"; filename*=UTF-8''{Uri.EscapeDataString(name)}"; + + internal static bool TryParseHttpDate(string? text, out DateTime utc) + { + utc = default; + if (string.IsNullOrWhiteSpace(text)) + return false; + // Algunos navegadores antiguos anaden "; length=NNN" tras la fecha. + int semi = text.IndexOf(';'); + if (semi >= 0) + text = text[..semi]; + return DateTime.TryParse(text.Trim(), CultureInfo.InvariantCulture, + DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out utc); + } + + private static DateTime TruncateToSeconds(DateTime utc) => + new(utc.Ticks - utc.Ticks % TimeSpan.TicksPerSecond, DateTimeKind.Utc); +} diff --git a/src/Sunfish.Core/Web/WebDav.cs b/src/Sunfish.Core/Web/WebDav.cs new file mode 100644 index 0000000..722264a --- /dev/null +++ b/src/Sunfish.Core/Web/WebDav.cs @@ -0,0 +1,598 @@ +using System.Globalization; +using System.Text; +using System.Xml; +using Sunfish.Config; +using Sunfish.Http; +using Sunfish.Shares; + +namespace Sunfish.Web; + +/// +/// WebDAV (clase 1 y LOCK simulado, se anuncia clase 2) sobre el mismo puerto y arbol que la web, +/// para montar los shares desde el Explorador, Finder o GVfs, y para que un Sunfish explore a otro. +/// GET, HEAD y PUT son los de la web. Los bloqueos devuelven tokens validos pero no bloquean +/// nada: el Explorador de Windows y Finder los exigen para escribir. +/// +public sealed partial class WebApp +{ + private const string DavNs = "DAV:"; + private const string AllowedMethods = "OPTIONS, GET, HEAD, POST, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK"; + private const int MaxXmlBytes = 1 << 20; + + private static bool IsDavMethod(string method) => + method is "PROPFIND" or "PROPPATCH" or "MKCOL" or "DELETE" or "COPY" or "MOVE" or "LOCK" or "UNLOCK"; + + /// Un recurso WebDAV: real (fichero o carpeta de un share) o carpeta virtual. + private sealed record DavNode(List Segments, ShareConfig? Share, IReadOnlyList Relative, + string? Physical, IReadOnlyList VirtualChildren) + { + public bool IsFile => Physical is not null && File.Exists(Physical); + public bool IsRealDirectory => Physical is not null && Directory.Exists(Physical); + public bool IsCollection => IsRealDirectory || (!IsFile && (VirtualChildren.Count > 0 || Segments.Count == 0)); + public bool Exists => IsFile || IsCollection; + + /// Se puede cambiar: dentro de un share, y no es su raiz. + public bool InsideShare => Share is not null && Relative.Count > 0; + } + + private static DavNode NodeOf(ServerState st, List segments) + { + var r = st.Tree.Resolve(segments); + string? physical = r.Share is null ? null : ShareFileSystem.TryGetPhysicalPath(r.Share, r.Relative); + return new DavNode(segments, r.Share, r.Relative, physical, r.VirtualChildren); + } + + private static async Task DavOptionsAsync(HttpContext ctx) + { + ctx.Response.Headers["DAV"] = "1, 2"; + ctx.Response.Headers["MS-Author-Via"] = "DAV"; + ctx.Response.Headers["Allow"] = AllowedMethods; + await ctx.Response.StartAsync(0, ctx.Aborted); + } + + private async Task DavAsync(HttpContext ctx, ServerState st, ClientProfile profile, List segments, Resolution resolution) + { + var node = NodeOf(st, segments); + if (node.Share is { } share) + { + if (!await AuthorizeAsync(ctx, st, profile, share)) + return; + if (!share.WebDav) + { + ctx.Response.Headers["Allow"] = "GET, HEAD, POST, PUT"; + await SendTextAsync(ctx, 405, "WebDAV is disabled for this share"); + return; + } + } + ctx.Response.Headers["DAV"] = "1, 2"; + ctx.Response.Headers["MS-Author-Via"] = "DAV"; + switch (ctx.Request.Method) + { + case "PROPFIND": await PropFindAsync(ctx, st, node); break; + case "PROPPATCH": await PropPatchAsync(ctx, node); break; + case "MKCOL": await MkColAsync(ctx, node); break; + case "DELETE": await DavDeleteAsync(ctx, node); break; + case "COPY": await CopyMoveAsync(ctx, st, node, move: false); break; + case "MOVE": await CopyMoveAsync(ctx, st, node, move: true); break; + case "LOCK": await LockAsync(ctx, node); break; + case "UNLOCK": + ctx.Response.StatusCode = 204; + await ctx.Response.StartAsync(0, ctx.Aborted); + break; + } + _ = resolution; + } + + // ------------------------------------------------------------------ PROPFIND + + private static readonly string[] AllProps = + ["displayname", "resourcetype", "getcontentlength", "getcontenttype", "getlastmodified", "creationdate", "getetag", "supportedlock", "lockdiscovery"]; + + private async Task PropFindAsync(HttpContext ctx, ServerState st, DavNode node) + { + if (!node.Exists) + { + await SendTextAsync(ctx, 404, "Not found"); + return; + } + string depth = (ctx.Request.Headers["Depth"] ?? "1").Trim(); + if (depth.Equals("infinity", StringComparison.OrdinalIgnoreCase)) + { + ctx.Response.StatusCode = 403; + await SendXmlAsync(ctx, 403, w => + { + w.WriteStartElement("D", "error", DavNs); + w.WriteElementString("D", "propfind-finite-depth", DavNs, ""); + w.WriteEndElement(); + }); + return; + } + // Sin cuerpo = allprop. Con , solo las pedidas; con , solo los nombres. + var (requested, namesOnly) = ParsePropFind(await ReadBodyAsync(ctx)); + if (node.Share is { Listing: false } && node.IsRealDirectory && depth == "1") + depth = "0"; // sin Listing no se enumera el contenido + + var entries = new List<(string Href, FileEntry Entry)>(); + string baseHref = DavHref(node.Segments, node.IsCollection); + entries.Add((baseHref, EntryOf(node))); + if (depth == "1" && node.IsCollection) + { + foreach (var child in node.VirtualChildren) + entries.Add((DavHref([.. node.Segments, child], true), new FileEntry(child, true, 0, default, IsVirtual: true))); + if (node.IsRealDirectory) + { + foreach (var e in ShareFileSystem.List(node.Physical!)) + { + if (node.VirtualChildren.Contains(e.Name, StringComparer.OrdinalIgnoreCase)) + continue; + entries.Add((DavHref([.. node.Segments, e.Name], e.IsDirectory), e)); + } + } + } + + await SendXmlAsync(ctx, 207, w => + { + w.WriteStartElement("D", "multistatus", DavNs); + foreach (var (href, entry) in entries) + WriteResponse(w, href, entry, requested, namesOnly); + w.WriteEndElement(); + }); + } + + private static FileEntry EntryOf(DavNode node) + { + string name = node.Segments.Count == 0 ? "/" : node.Segments[^1]; + if (node.IsFile) + { + var info = new FileInfo(node.Physical!); + return new FileEntry(name, false, info.Length, info.LastWriteTime); + } + if (node.IsRealDirectory) + return new FileEntry(name, true, 0, Directory.GetLastWriteTime(node.Physical!)); + return new FileEntry(name, true, 0, default, IsVirtual: true); + } + + private static void WriteResponse(XmlWriter w, string href, FileEntry e, List<(string Ns, string Name)>? requested, bool namesOnly) + { + var found = new List<(string Ns, string Name)>(); + var missing = new List<(string Ns, string Name)>(); + foreach (var p in requested ?? AllProps.Select(n => (DavNs, n)).ToList()) + { + if (p.Ns == DavNs && AllProps.Contains(p.Name) && (p.Name != "getcontentlength" && p.Name != "getcontenttype" || !e.IsDirectory)) + found.Add(p); + else + missing.Add(p); + } + + w.WriteStartElement("D", "response", DavNs); + w.WriteElementString("D", "href", DavNs, href); + w.WriteStartElement("D", "propstat", DavNs); + w.WriteStartElement("D", "prop", DavNs); + foreach (var (_, name) in found) + { + w.WriteStartElement("D", name, DavNs); + if (!namesOnly) + WritePropValue(w, name, e); + w.WriteEndElement(); + } + w.WriteEndElement(); + w.WriteElementString("D", "status", DavNs, "HTTP/1.1 200 OK"); + w.WriteEndElement(); + if (missing.Count > 0) + { + w.WriteStartElement("D", "propstat", DavNs); + w.WriteStartElement("D", "prop", DavNs); + foreach (var (ns, name) in missing) + w.WriteElementString(name, ns, ""); + w.WriteEndElement(); + w.WriteElementString("D", "status", DavNs, "HTTP/1.1 404 Not Found"); + w.WriteEndElement(); + } + w.WriteEndElement(); + } + + private static void WritePropValue(XmlWriter w, string name, FileEntry e) + { + var modified = e.IsVirtual ? DateTime.UtcNow : e.Modified.ToUniversalTime(); + switch (name) + { + case "displayname": w.WriteString(e.Name); break; + case "resourcetype": + if (e.IsDirectory) + w.WriteElementString("D", "collection", DavNs, ""); + break; + case "getcontentlength": w.WriteString(e.Size.ToString(CultureInfo.InvariantCulture)); break; + case "getcontenttype": w.WriteString(MimeTypes.For(e.Name)); break; + case "getlastmodified": w.WriteString(HttpResponse.FormatDate(modified)); break; + case "creationdate": w.WriteString(modified.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture)); break; + case "getetag": w.WriteString($"\"{modified.Ticks:x}-{e.Size:x}\""); break; + case "supportedlock": + w.WriteStartElement("D", "lockentry", DavNs); + w.WriteStartElement("D", "lockscope", DavNs); + w.WriteElementString("D", "exclusive", DavNs, ""); + w.WriteEndElement(); + w.WriteStartElement("D", "locktype", DavNs); + w.WriteElementString("D", "write", DavNs, ""); + w.WriteEndElement(); + w.WriteEndElement(); + break; + } + } + + /// Propiedades pedidas (null = todas) y si solo se quieren los nombres. + internal static (List<(string Ns, string Name)>? Props, bool NamesOnly) ParsePropFind(byte[] body) + { + if (body.Length == 0) + return (null, false); + List<(string, string)>? props = null; + bool namesOnly = false; + bool inProp = false; + int propDepth = 0; + using var reader = XmlReader.Create(new MemoryStream(body), SafeXml); + try + { + while (reader.Read()) + { + if (reader.NodeType == XmlNodeType.Element) + { + if (inProp && reader.Depth == propDepth + 1) + props!.Add((reader.NamespaceURI, reader.LocalName)); + else if (reader.NamespaceURI == DavNs && reader.LocalName == "prop") + { + inProp = true; + propDepth = reader.Depth; + props = new(); + } + else if (reader.NamespaceURI == DavNs && reader.LocalName == "propname") + namesOnly = true; + } + } + } + catch (XmlException) + { + return (null, false); + } + return (namesOnly ? null : props, namesOnly); + } + + // ------------------------------------------------------------------ PROPPATCH + + /// Se acepta y se responde que se aplico (el Explorador de Windows insiste en fijar sus atributos). + private async Task PropPatchAsync(HttpContext ctx, DavNode node) + { + if (!node.Exists) + { + await SendTextAsync(ctx, 404, "Not found"); + return; + } + var body = await ReadBodyAsync(ctx); + var names = new List<(string Ns, string Name)>(); + try + { + using var reader = XmlReader.Create(new MemoryStream(body), SafeXml); + int propDepth = -1; + while (reader.Read()) + { + if (reader.NodeType != XmlNodeType.Element) + continue; + if (reader.NamespaceURI == DavNs && reader.LocalName == "prop") + propDepth = reader.Depth; + else if (propDepth >= 0 && reader.Depth == propDepth + 1) + names.Add((reader.NamespaceURI, reader.LocalName)); + } + } + catch (XmlException) + { + await SendTextAsync(ctx, 400, "Bad XML"); + return; + } + string href = DavHref(node.Segments, node.IsCollection); + await SendXmlAsync(ctx, 207, w => + { + w.WriteStartElement("D", "multistatus", DavNs); + w.WriteStartElement("D", "response", DavNs); + w.WriteElementString("D", "href", DavNs, href); + w.WriteStartElement("D", "propstat", DavNs); + w.WriteStartElement("D", "prop", DavNs); + foreach (var (ns, name) in names) + w.WriteElementString(name, ns, ""); + w.WriteEndElement(); + w.WriteElementString("D", "status", DavNs, "HTTP/1.1 200 OK"); + w.WriteEndElement(); + w.WriteEndElement(); + w.WriteEndElement(); + }); + } + + // ------------------------------------------------------------------ cambios + + private async Task MkColAsync(HttpContext ctx, DavNode node) + { + if (ctx.Request.Body is RequestBodyStream body && !body.Completed && ctx.Request.Headers["Content-Length"] is not (null or "0")) + { + await SendTextAsync(ctx, 415, "MKCOL with a body is not supported"); + return; + } + if (node.Exists) + { + await SendTextAsync(ctx, 405, "Already exists"); + return; + } + if (!node.InsideShare || !node.Share!.Upload) + { + await SendTextAsync(ctx, 403, Strings.ErrorText(403)); + return; + } + string? parent = Path.GetDirectoryName(node.Physical!); + if (node.Physical is null || parent is null || !Directory.Exists(parent)) + { + await SendTextAsync(ctx, 409, "Parent does not exist"); + return; + } + var result = FileOperations.CreateFolder(parent, node.Segments[^1]); + if (result != FileOpResult.Ok) + { + await SendTextAsync(ctx, result == FileOpResult.InvalidName ? 400 : 409, Strings.OpResult(result, node.Segments[^1])); + return; + } + log.Info($"{ctx.Request.RemoteEndPoint?.Address} DAV MKCOL /{string.Join('/', node.Segments)}"); + ctx.Response.StatusCode = 201; + await ctx.Response.StartAsync(0, ctx.Aborted); + } + + private async Task DavDeleteAsync(HttpContext ctx, DavNode node) + { + if (!node.Exists) + { + await SendTextAsync(ctx, 404, "Not found"); + return; + } + if (!node.InsideShare || !node.Share!.Delete || !(node.IsFile || node.IsRealDirectory)) + { + await SendTextAsync(ctx, 403, Strings.ErrorText(403)); + return; + } + var result = FileOperations.Delete(node.Physical!); + if (result != FileOpResult.Ok) + { + await SendTextAsync(ctx, 409, Strings.OpResult(result, node.Segments[^1])); + return; + } + // FileOperations.Delete ya se lleva el AppleDouble ("._nombre") del fichero. + log.Info($"{ctx.Request.RemoteEndPoint?.Address} DAV DELETE /{string.Join('/', node.Segments)}"); + ctx.Response.StatusCode = 204; + await ctx.Response.StartAsync(0, ctx.Aborted); + } + + private async Task CopyMoveAsync(HttpContext ctx, ServerState st, DavNode source, bool move) + { + if (!source.Exists) + { + await SendTextAsync(ctx, 404, "Not found"); + return; + } + if (!TryDestination(ctx.Request.Headers["Destination"], out var destSegments)) + { + await SendTextAsync(ctx, 400, "Bad Destination"); + return; + } + var dest = NodeOf(st, destSegments); + if (dest.Share != source.Share) + { + await SendTextAsync(ctx, 502, "Destination is in another share"); + return; + } + var share = source.Share; + bool allowed = share is not null && source.InsideShare && dest.InsideShare && + (source.IsFile || source.IsRealDirectory) && + (move ? share.Rename : share.Upload); + if (!allowed || dest.Physical is null) + { + await SendTextAsync(ctx, 403, Strings.ErrorText(403)); + return; + } + string? destParent = Path.GetDirectoryName(dest.Physical); + string name = dest.Segments[^1]; + bool nameOk = FileOperations.IsCreatableName(name) || (share!.WebDav && FileOperations.IsMacMetadataName(name)); + if (destParent is null || !Directory.Exists(destParent) || !nameOk) + { + await SendTextAsync(ctx, 409, "Bad destination"); + return; + } + + bool overwriteHeader = !string.Equals(ctx.Request.Headers["Overwrite"]?.Trim(), "F", StringComparison.OrdinalIgnoreCase); + bool existed = dest.Exists; + if (existed) + { + if (!overwriteHeader) + { + await SendTextAsync(ctx, 412, "Destination exists"); + return; + } + if (!share!.Delete || string.Equals(source.Physical, dest.Physical, StringComparison.OrdinalIgnoreCase)) + { + await SendTextAsync(ctx, 403, Strings.ErrorText(403)); + return; + } + FileOperations.Delete(dest.Physical); + } + + FileOpResult result; + if (move) + { + result = MoveIncludingMacMetadata(source.Physical!, dest.Physical); + } + else + { + result = CopyRecursive(source.Physical!, dest.Physical); + } + if (result != FileOpResult.Ok) + { + await SendTextAsync(ctx, 409, Strings.OpResult(result, name)); + return; + } + log.Info($"{ctx.Request.RemoteEndPoint?.Address} DAV {(move ? "MOVE" : "COPY")} /{string.Join('/', source.Segments)} -> /{string.Join('/', dest.Segments)}"); + ctx.Response.StatusCode = existed ? 204 : 201; + await ctx.Response.StartAsync(0, ctx.Aborted); + } + + /// Mueve sin la restriccion de nombres con punto (Finder renombra sus ._ temporales). + private static FileOpResult MoveIncludingMacMetadata(string source, string target) + { + string name = Path.GetFileName(target); + if (FileOperations.IsCreatableName(name)) + return FileOperations.Move(source, target); + try + { + if (File.Exists(source)) + File.Move(source, target); + else + Directory.Move(source, target); + return FileOpResult.Ok; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + return FileOpResult.Failed; + } + } + + private static FileOpResult CopyRecursive(string source, string target) + { + try + { + if (File.Exists(source)) + { + File.Copy(source, target, overwrite: false); + return FileOpResult.Ok; + } + if (target.StartsWith(Path.TrimEndingDirectorySeparator(source) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + return FileOpResult.Conflict; // dentro de si misma + Directory.CreateDirectory(target); + foreach (var info in new DirectoryInfo(source).EnumerateFileSystemInfos()) + { + if (info.LinkTarget is not null) + continue; // los enlaces no se siguen + var r = CopyRecursive(info.FullName, Path.Combine(target, info.Name)); + if (r != FileOpResult.Ok) + return r; + } + return FileOpResult.Ok; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + return FileOpResult.Failed; + } + } + + /// Destination: URL absoluta o ruta; se queda la ruta y se valida como cualquier otra. + internal static bool TryDestination(string? header, out List segments) + { + segments = []; + if (string.IsNullOrWhiteSpace(header)) + return false; + var (path, _) = HttpRequestReader.SplitTarget(header.Trim()); + if (!PathRules.TrySplitUrlPath(path, out segments, out _)) + return false; + return segments.Count == 0 || !segments[0].Equals(LocationPath.Reserved, StringComparison.OrdinalIgnoreCase); + } + + // ------------------------------------------------------------------ LOCK + + private async Task LockAsync(HttpContext ctx, DavNode node) + { + await ReadBodyAsync(ctx); + int status = 200; + if (!node.Exists) + { + // Bloquear un recurso que no existe crea un fichero vacio (lo hacen el Explorador y Finder). + if (!node.InsideShare || !node.Share!.Upload || node.Physical is null || + Path.GetDirectoryName(node.Physical) is not { } parent || !Directory.Exists(parent)) + { + await SendTextAsync(ctx, 409, "Cannot lock here"); + return; + } + var created = await FileOperations.SaveAsync(parent, node.Segments[^1], Stream.Null, false, ctx.Aborted, + allowMacMetadata: FileOperations.IsMacMetadataName(node.Segments[^1])); + if (created != FileOpResult.Ok) + { + await SendTextAsync(ctx, 409, Strings.OpResult(created, node.Segments[^1])); + return; + } + status = 201; + } + string token = ctx.Request.Headers["If"] is { } ifHeader && ifHeader.Contains("opaquelocktoken:", StringComparison.Ordinal) + ? ifHeader[(ifHeader.IndexOf("opaquelocktoken:", StringComparison.Ordinal))..].TrimEnd(')', '>', ' ') + : "opaquelocktoken:" + Guid.NewGuid(); + string href = DavHref(node.Segments, node.IsCollection); + ctx.Response.Headers["Lock-Token"] = $"<{token}>"; + await SendXmlAsync(ctx, status, w => + { + w.WriteStartElement("D", "prop", DavNs); + w.WriteStartElement("D", "lockdiscovery", DavNs); + w.WriteStartElement("D", "activelock", DavNs); + w.WriteStartElement("D", "locktype", DavNs); + w.WriteElementString("D", "write", DavNs, ""); + w.WriteEndElement(); + w.WriteStartElement("D", "lockscope", DavNs); + w.WriteElementString("D", "exclusive", DavNs, ""); + w.WriteEndElement(); + w.WriteElementString("D", "depth", DavNs, "0"); + w.WriteElementString("D", "timeout", DavNs, "Second-3600"); + w.WriteStartElement("D", "locktoken", DavNs); + w.WriteElementString("D", "href", DavNs, token); + w.WriteEndElement(); + w.WriteStartElement("D", "lockroot", DavNs); + w.WriteElementString("D", "href", DavNs, href); + w.WriteEndElement(); + w.WriteEndElement(); + w.WriteEndElement(); + w.WriteEndElement(); + }); + } + + // ------------------------------------------------------------------ utilidades + + private static readonly XmlReaderSettings SafeXml = new() + { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null, + IgnoreComments = true, + IgnoreWhitespace = true, + MaxCharactersInDocument = MaxXmlBytes, + }; + + /// "/fotos/a%20b/" (las colecciones acaban en '/'). + private static string DavHref(IEnumerable segments, bool collection) + { + string path = "/" + string.Join('/', segments.Select(HtmlBuilder.UrlSegment)); + return collection && path != "/" ? path + "/" : path; + } + + private static async Task ReadBodyAsync(HttpContext ctx) + { + var ms = new MemoryStream(); + var buffer = new byte[8192]; + int n; + while ((n = await ctx.Request.Body.ReadAsync(buffer, ctx.Aborted)) > 0) + { + ms.Write(buffer, 0, n); + if (ms.Length > MaxXmlBytes) + throw new InvalidDataException("XML body too large"); + } + return ms.ToArray(); + } + + private static async Task SendXmlAsync(HttpContext ctx, int status, Action write) + { + var ms = new MemoryStream(); + using (var w = XmlWriter.Create(ms, new XmlWriterSettings { Encoding = new UTF8Encoding(false) })) + { + w.WriteStartDocument(); + write(w); + w.WriteEndDocument(); + } + ctx.Response.StatusCode = status; + ctx.Response.Headers["Cache-Control"] = "no-cache"; + await ctx.Response.SendAsync(ms.ToArray(), "application/xml; charset=utf-8", ctx.Aborted); + } +} diff --git a/src/Sunfish.Core/Web/WebMac.cs b/src/Sunfish.Core/Web/WebMac.cs new file mode 100644 index 0000000..eb6c05e --- /dev/null +++ b/src/Sunfish.Core/Web/WebMac.cs @@ -0,0 +1,92 @@ +using Sunfish.Http; +using Sunfish.Mac; + +namespace Sunfish.Web; + +/// +/// Mac clasico y nombres cortos en la web (DOC/MAC-CLASICO.md, DOC/CLIENTES.md): las subidas en +/// MacBinary o BinHex se separan solas; a un Mac clasico se le entrega en MacBinary lo que tiene +/// resource fork; ?as=macbin y ?as=hqx lo piden explicitamente; DOS y Windows 3.1 reciben el +/// nombre 8.3 en la descarga y Mac clasico uno de 31 caracteres como mucho. +/// +public sealed partial class WebApp +{ + /// Tras una subida: si es MacBinary/BinHex y el share tiene Mac, lo separa. Devuelve el nombre final. + private async Task UnpackMacAsync(ShareRequest sr, string path) + { + if (!sr.Share.Mac) + return Path.GetFileName(path); + try + { + var (result, name) = await MacFiles.TryUnpackAsync(path, sr.Share.Overwrite, sr.Profile.Kind == ClientKind.Mac, sr.Ctx.Aborted); + if (result == MacFiles.Unpacked.Unpacked) + log.Info($"{sr.Ctx.Request.RemoteEndPoint?.Address} mac {Path.GetFileName(path)} -> {name}"); + else if (result == MacFiles.Unpacked.ExistsKept) + log.Warning($"{sr.Ctx.Request.RemoteEndPoint?.Address} mac {Path.GetFileName(path)}: name inside already exists, kept encoded"); + return name; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException or EndOfStreamException) + { + log.Warning($"{sr.Ctx.Request.RemoteEndPoint?.Address} mac {Path.GetFileName(path)}: {e.Message}"); + return Path.GetFileName(path); + } + } + + /// Descarga de un fichero de un share, con las adaptaciones del perfil del cliente. + private async Task SendShareFileAsync(ShareRequest sr, string physical) + { + var (ctx, share, profile) = (sr.Ctx, sr.Share, sr.Profile); + string name = Path.GetFileName(physical); + string? encoding = ctx.Request.GetQuery("as"); + bool macFork = share.Mac && MacFiles.HasCompanion(physical); + + if (share.Mac && (encoding is "macbin" or "hqx" || (encoding is null && profile.Kind == ClientKind.Mac && macFork))) + { + await SendMacEncodedAsync(ctx, physical, encoding == "hqx", profile); + return; + } + + // Nombre de la descarga segun el cliente: 8.3 para DOS y Windows 3.1, 31 caracteres para Mac. + string? downloadName = null; + if (profile.UsesShortNames && share.ShortNames) + { + var dir = Path.GetDirectoryName(physical)!; + downloadName = Shares.ShortNames.Map(new DirectoryInfo(dir).EnumerateFileSystemInfos().Select(i => i.Name)).GetValueOrDefault(name); + } + else if (profile.Kind == ClientKind.Mac && name.Length > 31) + { + downloadName = MacNames.Shorten(name, 31); + } + if (downloadName is not null && downloadName != name) + ctx.Response.Headers["Content-Disposition"] = (ctx.Request.HasQuery("dl") ? "attachment" : "inline") + + $"; filename=\"{AsciiOnly(downloadName)}\""; + await SendFileAsync(ctx, physical); + } + + /// MacBinary III (con Content-Length) o BinHex 4.0 del fichero con su AppleDouble. + private static async Task SendMacEncodedAsync(HttpContext ctx, string physical, bool binHex, ClientProfile profile) + { + var file = MacFiles.Describe(physical); + long dataLength = new FileInfo(physical).Length; + string baseName = profile.Kind == ClientKind.Mac ? MacNames.Shorten(file.Name, 27) : file.Name; + if (binHex) + { + if (dataLength > MacFiles.MaxBinHex) + { + await SendTextAsync(ctx, 413, Strings.ErrorTitle(413)); + return; + } + var text = BinHex.Encode(file, await File.ReadAllBytesAsync(physical, ctx.Aborted)); + ctx.Response.Headers["Content-Disposition"] = ContentDisposition(baseName + ".hqx"); + await ctx.Response.SendAsync(text, "application/mac-binhex40", ctx.Aborted); + return; + } + ctx.Response.Headers["Content-Type"] = "application/x-macbinary"; + ctx.Response.Headers["Content-Disposition"] = ContentDisposition(baseName + ".bin"); + var body = await ctx.Response.StartAsync(MacBinary.EncodedLength(dataLength, file.ResourceFork.Length), ctx.Aborted); + if (ctx.Request.IsHead) + return; + await using var data = new FileStream(physical, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 1, useAsync: true); + await MacBinary.EncodeAsync(file, data, dataLength, body, ctx.Aborted); + } +} diff --git a/src/Sunfish.Core/Web/WebResources.cs b/src/Sunfish.Core/Web/WebResources.cs new file mode 100644 index 0000000..24062b7 --- /dev/null +++ b/src/Sunfish.Core/Web/WebResources.cs @@ -0,0 +1,21 @@ +using System.Collections.Concurrent; + +namespace Sunfish.Web; + +/// Recursos incrustados de la web (iconos GIF, favicon), servidos en /$sunfish/res/. +internal static class WebResources +{ + private static readonly ConcurrentDictionary Cache = new(StringComparer.OrdinalIgnoreCase); + + public static byte[]? Get(string name) => Cache.GetOrAdd(name, Load); + + private static byte[]? Load(string name) + { + using var stream = typeof(WebResources).Assembly.GetManifestResourceStream("web/" + name); + if (stream is null) + return null; + var ms = new MemoryStream(); + stream.CopyTo(ms); + return ms.ToArray(); + } +} diff --git a/src/Sunfish.Core/Web/ZipStreamWriter.cs b/src/Sunfish.Core/Web/ZipStreamWriter.cs new file mode 100644 index 0000000..fd7b129 --- /dev/null +++ b/src/Sunfish.Core/Web/ZipStreamWriter.cs @@ -0,0 +1,236 @@ +using System.Buffers.Binary; +using System.Text; +using Sunfish.Shares; + +namespace Sunfish.Web; + +/// +/// ZIP generado en flujo, sin fichero temporal, pensado para descompresores antiguos (PKZIP 2.x, +/// StuffIt): metodo Stored, CRC calculado antes de cada fichero (sin data descriptors) y tamano +/// total conocido de antemano (Content-Length, para que el navegador muestre el progreso). +/// Nombres no ASCII: para clientes antiguos en CP437 si se pueden representar (lo que esperan +/// PKZIP y compania); para los modernos, y si CP437 no basta, en UTF-8 con su indicador (los +/// descompresores actuales leen como UTF-8 los nombres sin indicador). +/// Cada fichero se lee dos veces (CRC y envio). Sin ZIP64: hasta 4 GiB y 65535 entradas. +/// +internal sealed class ZipStreamWriter +{ + public sealed record Entry(string Name, string? Physical, long Size, DateTime Modified) + { + public bool IsDirectory => Physical is null; + } + + private sealed record Planned(Entry Entry, byte[] Name, bool Utf8, long Offset); + + private readonly List _plan = new(); + + public long TotalLength { get; } + + public int Count => _plan.Count; + + public const long MaxLength = uint.MaxValue - 1; + public const int MaxEntries = ushort.MaxValue - 1; + + public ZipStreamWriter(IEnumerable entries, bool preferUtf8) + { + long offset = 0; + long central = 0; + foreach (var entry in entries) + { + var (name, utf8) = EncodeName(entry.Name, preferUtf8); + _plan.Add(new Planned(entry, name, utf8, offset)); + offset += 30 + name.Length + entry.Size; + central += 46 + name.Length; + } + TotalLength = offset + central + 22; + } + + public bool TooLarge => TotalLength > MaxLength || _plan.Count > MaxEntries; + + /// Recorre una carpeta para el ZIP: sin ocultos ni enlaces (no se siguen). + public static List Collect(string directory, string prefix = "") + { + var result = new List(); + foreach (var e in ShareFileSystem.List(directory).OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase)) + { + if (e.IsLink) + continue; + string physical = Path.Combine(directory, e.Name); + string name = prefix + e.Name; + if (e.IsDirectory) + { + result.Add(new Entry(name + "/", null, 0, e.Modified)); + result.AddRange(Collect(physical, name + "/")); + } + else + { + result.Add(new Entry(name, physical, e.Size, e.Modified)); + } + } + return result; + } + + public async Task WriteAsync(Stream output, CancellationToken ct) + { + var crcs = new uint[_plan.Count]; + var buffer = new byte[65536]; + for (int i = 0; i < _plan.Count; i++) + { + var p = _plan[i]; + uint crc = 0; + if (!p.Entry.IsDirectory) + crc = await Crc32.OfFileAsync(p.Entry.Physical!, p.Entry.Size, buffer, ct); + crcs[i] = crc; + await output.WriteAsync(LocalHeader(p, crc), ct); + if (!p.Entry.IsDirectory) + await CopyExactAsync(p.Entry.Physical!, p.Entry.Size, output, buffer, ct); + } + long centralStart = _plan.Count == 0 ? 0 : _plan[^1].Offset + 30 + _plan[^1].Name.Length + _plan[^1].Entry.Size; + long centralSize = 0; + for (int i = 0; i < _plan.Count; i++) + { + var header = CentralHeader(_plan[i], crcs[i]); + centralSize += header.Length; + await output.WriteAsync(header, ct); + } + await output.WriteAsync(EndOfCentralDirectory(centralStart, centralSize), ct); + } + + private static async Task CopyExactAsync(string path, long size, Stream output, byte[] buffer, CancellationToken ct) + { + await using var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 1, useAsync: true); + long remaining = size; + while (remaining > 0) + { + int n = await file.ReadAsync(buffer.AsMemory(0, (int)Math.Min(buffer.Length, remaining)), ct); + if (n == 0) + throw new IOException("File changed while zipping: " + path); + await output.WriteAsync(buffer.AsMemory(0, n), ct); + remaining -= n; + } + } + + private static byte[] LocalHeader(Planned p, uint crc) + { + var h = new byte[30 + p.Name.Length]; + var s = h.AsSpan(); + BinaryPrimitives.WriteUInt32LittleEndian(s, 0x04034b50); + BinaryPrimitives.WriteUInt16LittleEndian(s[4..], 20); + BinaryPrimitives.WriteUInt16LittleEndian(s[6..], Flags(p)); + BinaryPrimitives.WriteUInt16LittleEndian(s[8..], 0); // Stored + WriteDosTime(s[10..], p.Entry.Modified); + BinaryPrimitives.WriteUInt32LittleEndian(s[14..], crc); + BinaryPrimitives.WriteUInt32LittleEndian(s[18..], (uint)p.Entry.Size); + BinaryPrimitives.WriteUInt32LittleEndian(s[22..], (uint)p.Entry.Size); + BinaryPrimitives.WriteUInt16LittleEndian(s[26..], (ushort)p.Name.Length); + BinaryPrimitives.WriteUInt16LittleEndian(s[28..], 0); + p.Name.CopyTo(s[30..]); + return h; + } + + private static byte[] CentralHeader(Planned p, uint crc) + { + var h = new byte[46 + p.Name.Length]; + var s = h.AsSpan(); + BinaryPrimitives.WriteUInt32LittleEndian(s, 0x02014b50); + BinaryPrimitives.WriteUInt16LittleEndian(s[4..], 20); // creado por: MS-DOS, version 2.0 + BinaryPrimitives.WriteUInt16LittleEndian(s[6..], 20); + BinaryPrimitives.WriteUInt16LittleEndian(s[8..], Flags(p)); + BinaryPrimitives.WriteUInt16LittleEndian(s[10..], 0); + WriteDosTime(s[12..], p.Entry.Modified); + BinaryPrimitives.WriteUInt32LittleEndian(s[16..], crc); + BinaryPrimitives.WriteUInt32LittleEndian(s[20..], (uint)p.Entry.Size); + BinaryPrimitives.WriteUInt32LittleEndian(s[24..], (uint)p.Entry.Size); + BinaryPrimitives.WriteUInt16LittleEndian(s[28..], (ushort)p.Name.Length); + // extra, comentario, disco y atributos internos a cero + BinaryPrimitives.WriteUInt32LittleEndian(s[38..], p.Entry.IsDirectory ? 0x10u : 0x20u); + BinaryPrimitives.WriteUInt32LittleEndian(s[42..], (uint)p.Offset); + p.Name.CopyTo(s[46..]); + return h; + } + + private byte[] EndOfCentralDirectory(long centralStart, long centralSize) + { + var h = new byte[22]; + var s = h.AsSpan(); + BinaryPrimitives.WriteUInt32LittleEndian(s, 0x06054b50); + BinaryPrimitives.WriteUInt16LittleEndian(s[8..], (ushort)_plan.Count); + BinaryPrimitives.WriteUInt16LittleEndian(s[10..], (ushort)_plan.Count); + BinaryPrimitives.WriteUInt32LittleEndian(s[12..], (uint)centralSize); + BinaryPrimitives.WriteUInt32LittleEndian(s[16..], (uint)centralStart); + return h; + } + + private static ushort Flags(Planned p) => p.Utf8 ? (ushort)0x0800 : (ushort)0; + + internal static (byte[] Name, bool Utf8) EncodeName(string name, bool preferUtf8) + { + if (name.All(char.IsAscii)) + return (Encoding.ASCII.GetBytes(name), false); + if (preferUtf8) + return (Encoding.UTF8.GetBytes(name), true); + try + { + return (Encodings.Cp437Strict.GetBytes(name), false); + } + catch (EncoderFallbackException) + { + return (Encoding.UTF8.GetBytes(name), true); + } + } + + private static void WriteDosTime(Span s, DateTime time) + { + if (time.Year < 1980) + time = new DateTime(1980, 1, 1); + if (time.Year > 2107) + time = new DateTime(2107, 12, 31, 23, 59, 58); + ushort dosTime = (ushort)((time.Hour << 11) | (time.Minute << 5) | (time.Second / 2)); + ushort dosDate = (ushort)(((time.Year - 1980) << 9) | (time.Month << 5) | time.Day); + BinaryPrimitives.WriteUInt16LittleEndian(s, dosTime); + BinaryPrimitives.WriteUInt16LittleEndian(s[2..], dosDate); + } +} + +/// CRC-32 (el de ZIP). System.IO.Hashing no esta en la BCL. +internal static class Crc32 +{ + private static readonly uint[] Table = BuildTable(); + + private static uint[] BuildTable() + { + var table = new uint[256]; + for (uint i = 0; i < 256; i++) + { + uint c = i; + for (int k = 0; k < 8; k++) + c = (c & 1) != 0 ? 0xEDB88320u ^ (c >> 1) : c >> 1; + table[i] = c; + } + return table; + } + + public static uint Update(uint crc, ReadOnlySpan data) + { + crc = ~crc; + foreach (byte b in data) + crc = Table[(crc ^ b) & 0xFF] ^ (crc >> 8); + return ~crc; + } + + public static async Task OfFileAsync(string path, long size, byte[] buffer, CancellationToken ct) + { + await using var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 1, useAsync: true); + uint crc = 0; + long remaining = size; + while (remaining > 0) + { + int n = await file.ReadAsync(buffer.AsMemory(0, (int)Math.Min(buffer.Length, remaining)), ct); + if (n == 0) + throw new IOException("File changed while zipping: " + path); + crc = Update(crc, buffer.AsSpan(0, n)); + remaining -= n; + } + return crc; + } +} diff --git a/src/Sunfish.WinForms/AdminDialog.cs b/src/Sunfish.WinForms/AdminDialog.cs new file mode 100644 index 0000000..1b9597e --- /dev/null +++ b/src/Sunfish.WinForms/AdminDialog.cs @@ -0,0 +1,97 @@ +using Sunfish.Auth; +using Sunfish.Config; +using Sunfish.Net; + +namespace Sunfish.WinForms; + +/// +/// Panel de administracion web: activarlo, contrasena (usuario "admin") y desde donde se permite. +/// Compacto y vertical, etiqueta encima de cada campo, como el dialogo de share. +/// +internal sealed class AdminDialog : Form +{ + private const int FieldWidth = 230; + private readonly AdminSettings _admin; + private readonly CheckBox _enabled = new() { AutoSize = true, Margin = new Padding(3, 3, 3, 6) }; + private readonly TextBox _password = new() { Width = FieldWidth, UseSystemPasswordChar = true }; + private readonly TextBox _repeat = new() { Width = FieldWidth, UseSystemPasswordChar = true }; + private readonly TextBox _allow = new() { Width = FieldWidth }; + + public AdminDialog(AdminSettings admin, int httpPort) + { + _admin = admin; + SuspendLayout(); + AutoScaleDimensions = new SizeF(96F, 96F); + AutoScaleMode = AutoScaleMode.Dpi; + Text = Strings.AdminPanel; + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = MinimizeBox = false; + ShowInTaskbar = false; + StartPosition = FormStartPosition.CenterParent; + AutoSize = true; + AutoSizeMode = AutoSizeMode.GrowAndShrink; + Padding = new Padding(8, 6, 8, 8); + + _enabled.Text = Texts.AdminEnable; + _enabled.Checked = admin.Enabled; + _allow.Text = string.Join(", ", admin.AllowFrom); + + var main = new FlowLayoutPanel + { + FlowDirection = FlowDirection.TopDown, AutoSize = true, WrapContents = false, + Location = new Point(Padding.Left, Padding.Top), Margin = new Padding(0), + }; + main.Controls.Add(_enabled); + main.Controls.Add(Caption(admin.PasswordHash.Length > 0 ? Texts.AdminPasswordKeep : Strings.FieldPassword)); + main.Controls.Add(_password); + main.Controls.Add(Caption(Strings.AdminRepeat)); + main.Controls.Add(_repeat); + main.Controls.Add(Caption(Strings.AdminAllowFrom)); + main.Controls.Add(_allow); + main.Controls.Add(new Label + { + Text = Texts.AdminHelp($"http://:{httpPort}/$sunfish/admin/"), AutoSize = true, + MaximumSize = new Size(FieldWidth, 0), ForeColor = SystemColors.GrayText, Margin = new Padding(3, 6, 3, 3), + }); + + var ok = new Button { Text = Texts.Ok, Size = new Size(80, 25), UseVisualStyleBackColor = true, Margin = new Padding(3, 0, 3, 0) }; + ok.Click += (_, _) => Accept(); + var cancel = new Button { Text = Texts.Cancel, Size = new Size(80, 25), DialogResult = DialogResult.Cancel, UseVisualStyleBackColor = true, Margin = new Padding(3, 0, 3, 0) }; + var buttons = new FlowLayoutPanel { FlowDirection = FlowDirection.RightToLeft, Width = FieldWidth + 6, Height = 25, Margin = new Padding(0, 10, 0, 0) }; + buttons.Controls.AddRange([cancel, ok]); + main.Controls.Add(buttons); + AcceptButton = ok; + CancelButton = cancel; + Controls.Add(main); + ResumeLayout(false); + PerformLayout(); + } + + private static Label Caption(string text) => new() { Text = text + ":", AutoSize = true, Margin = new Padding(3, 6, 3, 1) }; + + private void Accept() + { + var rules = _allow.Text.Split([',', ' ', ';'], StringSplitOptions.RemoveEmptyEntries).ToList(); + var bad = rules.FirstOrDefault(r => !AddressRules.IsValid(r)); + if (rules.Count == 0 || bad is not null) + { + MessageBox.Show(this, Strings.AdminBadAllow(bad ?? ""), Text, MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + if (_password.Text != _repeat.Text) + { + MessageBox.Show(this, Strings.AdminPasswordMismatch, Text, MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + if (_enabled.Checked && _password.Text.Length == 0 && _admin.PasswordHash.Length == 0) + { + MessageBox.Show(this, Strings.ErrorPasswordEmpty, Text, MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + _admin.Enabled = _enabled.Checked; + _admin.AllowFrom = rules; + if (_password.Text.Length > 0) + _admin.PasswordHash = Passwords.Hash(_password.Text); + DialogResult = DialogResult.OK; + } +} diff --git a/src/Sunfish.WinForms/ExplorerForm.cs b/src/Sunfish.WinForms/ExplorerForm.cs new file mode 100644 index 0000000..e9bca3c --- /dev/null +++ b/src/Sunfish.WinForms/ExplorerForm.cs @@ -0,0 +1,396 @@ +using System.Globalization; +using Sunfish.Dav; +using Sunfish.Net; + +namespace Sunfish.WinForms; + +/// +/// Explorar otro servidor (otro Sunfish o cualquier WebDAV) y copiar en los dos sentidos, con el +/// mismo aspecto compacto que la ventana principal. Los Sunfish de la red local aparecen solos +/// (DNS-SD); tambien se puede escribir una direccion. Se suben ficheros y carpetas arrastrandolos. +/// +internal sealed class ExplorerForm : Form +{ + private readonly ToolTip _tips = new(); + private readonly ComboBox _address = new() { Location = new Point(6, 6), Width = 250, Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right }; + private readonly Button _go = new() { Text = "»", Size = new Size(26, 23), Location = new Point(260, 5), Anchor = AnchorStyles.Top | AnchorStyles.Right, UseVisualStyleBackColor = true }; + private readonly Label _path = new() { Location = new Point(6, 34), AutoSize = false, Size = new Size(280, 18), AutoEllipsis = true, Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right }; + private readonly ListView _list = new() + { + Location = new Point(0, 54), Size = new Size(292, 270), View = View.Details, FullRowSelect = true, HideSelection = false, + AllowDrop = true, BorderStyle = BorderStyle.FixedSingle, + Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right, + }; + private readonly Label _status = new() { Location = new Point(6, 328), Size = new Size(280, 18), AutoEllipsis = true, ForeColor = SystemColors.GrayText, Anchor = AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right }; + private readonly Button _up, _download, _upload, _mkdir, _delete; + private readonly Button _close = new() { Size = new Size(70, 26), UseVisualStyleBackColor = true, Anchor = AnchorStyles.Bottom | AnchorStyles.Right }; + private readonly ImageList _icons = new() { ColorDepth = ColorDepth.Depth32Bit, ImageSize = new Size(UiImages.Px(16), UiImages.Px(16)) }; + + private List _discovered = new(); + private DavClient? _client; + private string _folder = "/"; + private string? _user, _password; + private bool _busy; + private static string? _lastLocalFolder; + + public ExplorerForm() + { + _up = UiImages.IconButton("up", Texts.ExplorerUp, _tips); + _download = UiImages.IconButton("download", Texts.ExplorerDownload, _tips); + _upload = UiImages.IconButton("upload", Texts.ExplorerUpload, _tips); + _mkdir = UiImages.IconButton("plus", Texts.ExplorerNewFolder, _tips); + _delete = UiImages.IconButton("delete", Texts.ExplorerDelete, _tips); + + SuspendLayout(); + AutoScaleDimensions = new SizeF(96F, 96F); + AutoScaleMode = AutoScaleMode.Dpi; + Text = Texts.ExplorerTitle; + ClientSize = new Size(292, 384); + MinimumSize = new Size(280, 300); + StartPosition = FormStartPosition.CenterParent; + ShowInTaskbar = true; + + _icons.Images.Add("dir", UiImages.Get("foldericon")); + _icons.Images.Add("file", UiImages.Get("file")); + _list.SmallImageList = _icons; + _list.Columns.Add(Texts.ColName, 160); + _list.Columns.Add(Strings.ColSize, 60, HorizontalAlignment.Right); + _list.Columns.Add(Strings.ColDate, 110); + + int y = 384 - 31; + var buttons = new[] { _up, _download, _upload, _mkdir, _delete }; + for (int i = 0; i < buttons.Length; i++) + { + buttons[i].Location = new Point(6 + i * 29 + (i >= 1 ? 6 : 0), y); + buttons[i].Anchor = AnchorStyles.Bottom | AnchorStyles.Left; + } + _close.Text = Texts.Close; + _close.Location = new Point(292 - 76, y); + _close.Click += (_, _) => Close(); + _tips.SetToolTip(_address, Texts.ExplorerAddressTip); + _tips.SetToolTip(_go, Texts.ExplorerGo); + + Controls.AddRange([_address, _go, _path, _list, _status, .. buttons, _close]); + ResumeLayout(false); + PerformLayout(); + + _go.Click += async (_, _) => await ConnectAsync(_address.Text); + _address.KeyDown += async (_, e) => + { + if (e.KeyCode == Keys.Enter) + { + e.SuppressKeyPress = true; + await ConnectAsync(_address.Text); + } + }; + _address.SelectionChangeCommitted += async (_, _) => + { + if (_address.SelectedIndex >= 0 && _address.SelectedIndex < _discovered.Count) + await ConnectAsync(_discovered[_address.SelectedIndex].Url); + }; + _list.DoubleClick += async (_, _) => await OpenSelectedAsync(); + _list.KeyDown += async (_, e) => + { + if (e.KeyCode == Keys.Enter) await OpenSelectedAsync(); + if (e.KeyCode == Keys.Back) await GoUpAsync(); + if (e.KeyCode == Keys.Delete) await DeleteAsync(); + }; + _list.DragEnter += (_, e) => e.Effect = _client is not null && e.Data?.GetDataPresent(DataFormats.FileDrop) == true ? DragDropEffects.Copy : DragDropEffects.None; + _list.DragDrop += async (_, e) => + { + if (e.Data?.GetData(DataFormats.FileDrop) is string[] paths) + await UploadAsync(paths); + }; + _up.Click += async (_, _) => await GoUpAsync(); + _download.Click += async (_, _) => await DownloadAsync(); + _upload.Click += async (_, _) => await PickAndUploadAsync(); + _mkdir.Click += async (_, _) => await NewFolderAsync(); + _delete.Click += async (_, _) => await DeleteAsync(); + UpdateButtons(); + } + + protected override async void OnShown(EventArgs e) + { + base.OnShown(e); + await DiscoverAsync(); + } + + protected override void OnFormClosed(FormClosedEventArgs e) + { + _client?.Dispose(); + base.OnFormClosed(e); + } + + // ------------------------------------------------------------------ conexion + + private async Task DiscoverAsync() + { + SetStatus(Texts.ExplorerSearching); + try + { + _discovered = await Discovery.BrowseAsync(TimeSpan.FromSeconds(2)); + } + catch (Exception e) when (e is System.Net.Sockets.SocketException or OperationCanceledException) + { + _discovered = new(); + } + if (IsDisposed) + return; + string typed = _address.Text; + _address.Items.Clear(); + foreach (var s in _discovered) + _address.Items.Add($"{s.Name} ({s.Address}:{s.Port})"); + _address.Text = typed; + SetStatus(_discovered.Count == 0 ? Texts.ExplorerNoneFound : Texts.ExplorerFound(_discovered.Count)); + if (_discovered.Count > 0 && _client is null) + _address.DroppedDown = true; + } + + private async Task ConnectAsync(string text) + { + if (!DavClient.TryParseAddress(text, out var server, out var path)) + { + SetStatus(Texts.ExplorerBadAddress); + return; + } + _client?.Dispose(); + _client = new DavClient(server, _user, _password); + await ShowFolderAsync(path); + } + + private async Task ShowFolderAsync(string folder) + { + if (_client is null) + return; + await RunAsync(async () => + { + var items = await WithCredentialsAsync(c => c.ListAsync(folder)); + _folder = folder.Length > 1 ? folder.TrimEnd('/') : "/"; + _path.Text = _client!.Server.Authority + _folder; + _list.BeginUpdate(); + _list.Items.Clear(); + foreach (var item in items) + { + var row = new ListViewItem(item.Name, item.IsCollection ? "dir" : "file") { Tag = item }; + row.SubItems.Add(item.IsCollection ? "" : FormatSize(item.Size)); + row.SubItems.Add(item.Modified?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) ?? ""); + _list.Items.Add(row); + } + _list.EndUpdate(); + SetStatus(Texts.ExplorerItems(items.Count)); + }); + } + + /// Si el servidor pide usuario (401), se pregunta y se repite con las credenciales. + private async Task WithCredentialsAsync(Func> action) + { + while (true) + { + try + { + return await action(_client!); + } + catch (DavException e) when (e.Status == 401) + { + using var dialog = new PasswordDialog(Texts.ExplorerLogin(_client!.Server.Authority), askName: true, askRepeat: false); + if (dialog.ShowDialog(this) != DialogResult.OK) + throw; + _user = dialog.UserName; + _password = dialog.Password; + var server = _client.Server; + _client.Dispose(); + _client = new DavClient(server, _user, _password); + } + } + } + + private Task WithCredentialsAsync(Func action) => + WithCredentialsAsync(async c => { await action(c); return true; }); + + // ------------------------------------------------------------------ acciones + + private IEnumerable Selected() => + _list.SelectedItems.Cast().Select(i => (DavItem)i.Tag!); + + private async Task OpenSelectedAsync() + { + var item = Selected().FirstOrDefault(); + if (item is { IsCollection: true }) + await ShowFolderAsync(item.Path); + else if (item is not null) + await DownloadAsync(); + } + + private Task GoUpAsync() => _folder == "/" ? Task.CompletedTask : ShowFolderAsync(DavClient.Parent(_folder)); + + private async Task DownloadAsync() + { + var items = Selected().ToList(); + if (items.Count == 0 || _client is null) + return; + using var picker = new FolderBrowserDialog + { + Description = Texts.ExplorerDownloadTo, + UseDescriptionForTitle = true, + SelectedPath = _lastLocalFolder ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + }; + if (picker.ShowDialog(this) != DialogResult.OK) + return; + _lastLocalFolder = picker.SelectedPath; + await RunAsync(async () => + { + var progress = Progress(); + foreach (var item in items) + await WithCredentialsAsync(c => c.DownloadTreeAsync(item, picker.SelectedPath, progress)); + SetStatus(Texts.ExplorerDone); + }); + } + + private async Task PickAndUploadAsync() + { + if (_client is null) + return; + using var dialog = new OpenFileDialog { Multiselect = true, Title = Texts.ExplorerUpload }; + if (dialog.ShowDialog(this) == DialogResult.OK) + await UploadAsync(dialog.FileNames); + } + + private async Task UploadAsync(string[] paths) + { + if (_client is null || paths.Length == 0) + return; + string folder = _folder; + await RunAsync(async () => + { + var progress = Progress(); + foreach (var path in paths) + await WithCredentialsAsync(c => c.UploadTreeAsync(path, folder, progress)); + }); + await ShowFolderAsync(folder); + } + + private async Task NewFolderAsync() + { + if (_client is null) + return; + using var dialog = new NameDialog(Texts.ExplorerNewFolder); + if (dialog.ShowDialog(this) != DialogResult.OK || dialog.Value.Length == 0) + return; + string folder = _folder; + await RunAsync(() => WithCredentialsAsync(c => c.CreateFolderAsync(DavClient.Combine(folder, dialog.Value)))); + await ShowFolderAsync(folder); + } + + private async Task DeleteAsync() + { + var items = Selected().ToList(); + if (items.Count == 0 || _client is null) + return; + string question = items.Count == 1 ? Texts.ExplorerDeleteOne(items[0].Name) : Texts.ExplorerDeleteMany(items.Count); + if (MessageBox.Show(this, question, Text, MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes) + return; + string folder = _folder; + await RunAsync(async () => + { + foreach (var item in items) + await WithCredentialsAsync(c => c.DeleteAsync(item.Path, item.IsCollection)); + }); + await ShowFolderAsync(folder); + } + + /// Ejecuta una operacion de red mostrando el error, sin bloquear la ventana. + private async Task RunAsync(Func action) + { + if (_busy) + return; + _busy = true; + UseWaitCursor = true; + UpdateButtons(); + try + { + await action(); + } + catch (DavException e) + { + SetStatus(e.Status == 403 ? Strings.ErrorText(403) : e.Message); + } + catch (Exception e) when (e is HttpRequestException or IOException or UnauthorizedAccessException or TaskCanceledException) + { + SetStatus(e.Message); + } + finally + { + _busy = false; + UseWaitCursor = false; + UpdateButtons(); + } + } + + private IProgress Progress() + { + long total = 0; + return new Progress(n => + { + total += n; + SetStatus(Texts.ExplorerTransferred(FormatSize(total))); + }); + } + + private void UpdateButtons() + { + bool connected = _client is not null && !_busy; + foreach (var b in new[] { _up, _download, _upload, _mkdir, _delete }) + b.Enabled = connected; + } + + private void SetStatus(string text) => _status.Text = text; + + private static string FormatSize(long bytes) + { + string[] units = ["B", "KB", "MB", "GB", "TB"]; + double value = bytes; + int unit = 0; + while (value >= 1024 && unit < units.Length - 1) + { + value /= 1024; + unit++; + } + return unit == 0 ? $"{bytes} B" : value.ToString("0.0", CultureInfo.InvariantCulture) + " " + units[unit]; + } +} + +/// Pide un nombre (carpeta nueva). +internal sealed class NameDialog : Form +{ + private readonly TextBox _text = new() { Width = 220 }; + + public string Value => _text.Text.Trim(); + + public NameDialog(string title) + { + SuspendLayout(); + AutoScaleDimensions = new SizeF(96F, 96F); + AutoScaleMode = AutoScaleMode.Dpi; + Text = title; + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = MinimizeBox = false; + ShowInTaskbar = false; + StartPosition = FormStartPosition.CenterParent; + AutoSize = true; + AutoSizeMode = AutoSizeMode.GrowAndShrink; + Padding = new Padding(10, 6, 10, 10); + var ok = new Button { Text = Texts.Ok, Size = new Size(70, 25), DialogResult = DialogResult.OK, UseVisualStyleBackColor = true }; + var cancel = new Button { Text = Texts.Cancel, Size = new Size(70, 25), DialogResult = DialogResult.Cancel, UseVisualStyleBackColor = true }; + var buttons = new FlowLayoutPanel { FlowDirection = FlowDirection.RightToLeft, Width = 226, Height = 32, Margin = new Padding(0, 10, 0, 0) }; + buttons.Controls.AddRange([cancel, ok]); + var main = new FlowLayoutPanel { FlowDirection = FlowDirection.TopDown, AutoSize = true, WrapContents = false }; + main.Controls.Add(new Label { Text = Strings.FieldName + ":", AutoSize = true, Margin = new Padding(3, 6, 3, 1) }); + main.Controls.Add(_text); + main.Controls.Add(buttons); + Controls.Add(main); + AcceptButton = ok; + CancelButton = cancel; + ResumeLayout(false); + PerformLayout(); + } +} diff --git a/src/Sunfish.WinForms/InfoForm.cs b/src/Sunfish.WinForms/InfoForm.cs new file mode 100644 index 0000000..b12c09b --- /dev/null +++ b/src/Sunfish.WinForms/InfoForm.cs @@ -0,0 +1,149 @@ +using Sunfish.Host; +using Sunfish.Log; +using Sunfish.Net; + +namespace Sunfish.WinForms; + +/// +/// Ventana de informacion (boton "i", como en la 2.0): direcciones de cada share por interfaz +/// (doble clic las abre, menu para copiarlas), el registro y el "acerca de". +/// +internal sealed class InfoForm : Form +{ + private readonly SunfishHost _host; + private readonly ListBox _addresses = new() { IntegralHeight = false, HorizontalScrollbar = true, Dock = DockStyle.Fill }; + private readonly ListBox _log = new() { IntegralHeight = false, HorizontalScrollbar = true, Dock = DockStyle.Fill }; + private readonly LinkLabel _adminLink = new() { AutoSize = true }; + private readonly Action _onLog; + private readonly Action _onChange; + + public InfoForm(SunfishHost host) + { + _host = host; + SuspendLayout(); + AutoScaleDimensions = new SizeF(96F, 96F); + AutoScaleMode = AutoScaleMode.Dpi; + Text = "Sunfish"; + ClientSize = new Size(360, 420); + MinimumSize = new Size(300, 300); + StartPosition = FormStartPosition.CenterParent; + ShowInTaskbar = false; + MinimizeBox = false; + Padding = new Padding(6); + + using (var stream = typeof(InfoForm).Assembly.GetManifestResourceStream("ui/logo.png")) + { + var header = new Panel { Dock = DockStyle.Top, Height = 56 }; + if (stream is not null) + header.Controls.Add(new PictureBox { Image = Image.FromStream(stream), SizeMode = PictureBoxSizeMode.Zoom, Bounds = new Rectangle(0, 2, 48, 48) }); + header.Controls.Add(new Label { Text = Texts.About, AutoSize = true, Location = new Point(56, 8) }); + _adminLink.Location = new Point(56, 30); + _adminLink.LinkClicked += async (_, _) => await EditAdminAsync(); + header.Controls.Add(_adminLink); + Controls.Add(header); + } + + var addressesBox = new GroupBox { Text = Strings.AppAddresses, Dock = DockStyle.Fill, Padding = new Padding(4) }; + addressesBox.Controls.Add(_addresses); + var logBox = new GroupBox { Text = Strings.AppLog, Dock = DockStyle.Fill, Padding = new Padding(4) }; + logBox.Controls.Add(_log); + var split = new SplitContainer { Dock = DockStyle.Fill, Orientation = Orientation.Horizontal, SplitterDistance = 150 }; + split.Panel1.Controls.Add(addressesBox); + split.Panel2.Controls.Add(logBox); + Controls.Add(split); + split.BringToFront(); + ResumeLayout(false); + PerformLayout(); + + var menu = new ContextMenuStrip(); + menu.Items.Add(Texts.OpenInBrowser, UiImages.Get("share"), (_, _) => MainForm.Open(SelectedUrl())); + menu.Items.Add(Texts.CopyUrl, null, (_, _) => + { + if (SelectedUrl() is { } url) + Clipboard.SetText(url); + }); + menu.Items.Add(Texts.ShowQr, null, (_, _) => + { + if (SelectedUrl() is { } url) + UiPlacement.ShowCentered(new QrForm(url) { Icon = Icon }, this); + }); + _addresses.ContextMenuStrip = menu; + _addresses.DoubleClick += (_, _) => MainForm.Open(SelectedUrl()); + + _onLog = entry => SafeInvoke(() => AddLog(entry)); + _onChange = () => SafeInvoke(FillAddresses); + _host.Log.Written += _onLog; + _host.Changed += _onChange; + foreach (var entry in _host.Log.Recent()) + AddLog(entry); + FillAddresses(); + } + + protected override void OnFormClosed(FormClosedEventArgs e) + { + _host.Log.Written -= _onLog; + _host.Changed -= _onChange; + base.OnFormClosed(e); + } + + private string? SelectedUrl() + { + if (_addresses.SelectedItem is not string text) + return null; + int space = text.IndexOf(' '); + bool url = text.StartsWith("http", StringComparison.Ordinal) || text.StartsWith("ftp", StringComparison.Ordinal); + return url ? (space < 0 ? text : text[..space]) : null; + } + + private async Task EditAdminAsync() + { + var copy = _host.Config.Clone(); + using var dialog = new AdminDialog(copy.Admin, _host.HttpPort); + if (dialog.ShowDialog(this) == DialogResult.OK) + await (copy.FilePath is null ? _host.ApplyAsync(copy) : _host.SaveAndApplyAsync(copy)); + } + + private void FillAddresses() + { + _adminLink.Text = Texts.AdminLink(_host.Config.Admin.Enabled && _host.Config.Admin.PasswordHash.Length > 0); + _addresses.Items.Clear(); + if (!_host.HttpRunning && !_host.FtpRunning) + { + _addresses.Items.Add(Strings.AppStopped); + return; + } + var addresses = LocalAddresses.Get(); + if (addresses.Count == 0) + _addresses.Items.Add(Texts.NoAddress); + foreach (var share in _host.Config.Shares.Where(s => s.Enabled)) + { + _addresses.Items.Add($"[{share.Name}]"); + foreach (var address in addresses) + { + if (_host.HttpRunning) + _addresses.Items.Add($"{LocalAddresses.HttpUrl(address.Address, _host.HttpPort, share)} ({address.Interface})"); + if (_host.FtpRunning && share.Ftp) + _addresses.Items.Add($"{LocalAddresses.FtpUrl(address.Address, _host.FtpPort, share)} ({address.Interface})"); + } + } + } + + private void AddLog(LogEntry entry) + { + _log.Items.Add($"{entry.Time:HH:mm:ss} {entry.Message}"); + while (_log.Items.Count > 1000) + _log.Items.RemoveAt(0); + _log.TopIndex = Math.Max(0, _log.Items.Count - 1); + } + + private void SafeInvoke(Action action) + { + if (IsDisposed || !IsHandleCreated) + return; + try + { + BeginInvoke(action); + } + catch (InvalidOperationException) { } + } +} diff --git a/src/Sunfish.WinForms/MainForm.cs b/src/Sunfish.WinForms/MainForm.cs new file mode 100644 index 0000000..ddf958a --- /dev/null +++ b/src/Sunfish.WinForms/MainForm.cs @@ -0,0 +1,495 @@ +using System.Diagnostics; +using System.Net.Sockets; +using Sunfish.Config; +using Sunfish.Host; +using Sunfish.Net; + +namespace Sunfish.WinForms; + +/// +/// Ventana principal con el aspecto de la 2.0: pequena y vertical. Arriba, menu raiz y puerto; +/// en medio, los shares con su icono de carpeta (se arrastran carpetas para compartirlas); abajo, +/// botones con imagen. Direcciones y registro estan en la ventana de informacion (i). "Ok" +/// manda la ventana al area de notificacion sin parar el servidor. +/// +internal sealed class MainForm : Form +{ + private const int ItemHeight = 32; + private const int TopHeight = 58; + private const int ClientHeight = 384; + + private readonly SunfishHost _host; + private readonly List _pendingFolders; + private readonly ToolTip _tips = new(); + private readonly CheckBox _rootMenu = new() { AutoSize = true, Margin = new Padding(3, 5, 6, 0) }; + private readonly CheckBox _listen = new() { AutoSize = true, Margin = new Padding(3, 5, 2, 0) }; + private readonly NumericUpDown _port = new() { Minimum = 1, Maximum = 65535, Width = 62, Margin = new Padding(0, 3, 0, 0) }; + private readonly CheckBox _ftp = new() { AutoSize = true, Margin = new Padding(3, 5, 2, 0) }; + private readonly NumericUpDown _ftpPort = new() { Minimum = 1, Maximum = 65535, Width = 62, Margin = new Padding(0, 3, 0, 0) }; + private readonly ListBox _shares = new() + { + Location = new Point(0, TopHeight), DrawMode = DrawMode.OwnerDrawFixed, ItemHeight = UiImages.Px(ItemHeight), + IntegralHeight = false, AllowDrop = true, BorderStyle = BorderStyle.FixedSingle, + Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right, + }; + private readonly Button _add; + private readonly Button _remove; + private readonly Button _edit; + private readonly Button _users; + private readonly Button _explore; + private readonly Button _info; + private readonly Button _ok = new() { UseVisualStyleBackColor = true, Size = new Size(70, 26), Anchor = AnchorStyles.Bottom | AnchorStyles.Right }; + private readonly ContextMenuStrip _menu = new() { ImageScalingSize = new Size(UiImages.Px(16), UiImages.Px(16)) }; + private readonly NotifyIcon _tray = new(); + private readonly Font _small; + private InfoForm? _infoForm; + private bool _updating; + + public MainForm(SunfishHost host, List pendingFolders) + { + _host = host; + _pendingFolders = pendingFolders; + _small = new Font(Font.FontFamily, Font.Size - 1f); + _add = UiImages.IconButton("plus", Texts.Add, _tips); + _remove = UiImages.IconButton("minus", Texts.Remove, _tips); + _edit = UiImages.IconButton("pencil", Texts.Edit, _tips); + _users = UiImages.IconButton("users", Texts.Users, _tips); + _explore = UiImages.IconButton("share", Texts.Explore, _tips); + _info = UiImages.IconButton("info", Texts.Info, _tips); + + SuspendLayout(); + AutoScaleDimensions = new SizeF(96F, 96F); + AutoScaleMode = AutoScaleMode.Dpi; + ClientSize = new Size(292, ClientHeight); + MinimumSize = new Size(260, 260); + Text = "Sunfish"; + Icon = LoadIcon(); + StartPosition = FormStartPosition.CenterScreen; + MaximizeBox = false; + + _rootMenu.Text = Texts.RootMenu; + _listen.Text = Texts.ListenOnPort; + _ftp.Text = Texts.FtpOnPort; + _tips.SetToolTip(_listen, Texts.ListenTip); + _tips.SetToolTip(_ftp, Texts.FtpTip); + _shares.Size = new Size(292, ClientHeight - TopHeight - 36); + _ok.Text = Texts.Hide; + _tips.SetToolTip(_ok, Texts.HideTip); + + int y = ClientHeight - 31; + _add.Location = new Point(6, y); + _remove.Location = new Point(35, y); + _edit.Location = new Point(64, y); + _users.Location = new Point(100, y); + _explore.Location = new Point(129, y); + _info.Location = new Point(292 - 70 - 6 - 32, y); + _ok.Location = new Point(292 - 70 - 6, y); + foreach (var b in new[] { _add, _remove, _edit, _users, _explore }) + b.Anchor = AnchorStyles.Bottom | AnchorStyles.Left; + _info.Anchor = AnchorStyles.Bottom | AnchorStyles.Right; + + // Cabecera: menu raiz y, en columna, HTTP y FTP con sus puertos alineados. Las columnas se + // ajustan solas al largo de los textos en cada idioma. + var top = new TableLayoutPanel + { + Location = new Point(2, 2), Size = new Size(288, TopHeight - 4), ColumnCount = 3, RowCount = 2, + Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right, + }; + top.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + top.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + top.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + top.Controls.Add(_rootMenu, 0, 0); + top.Controls.Add(_listen, 1, 0); + top.Controls.Add(_port, 2, 0); + top.Controls.Add(_ftp, 1, 1); + top.Controls.Add(_ftpPort, 2, 1); + Controls.AddRange([top, _shares, _add, _remove, _edit, _users, _explore, _info, _ok]); + ResumeLayout(false); + PerformLayout(); + + _rootMenu.CheckedChanged += async (_, _) => await ChangeRootMenuAsync(); + _listen.CheckedChanged += async (_, _) => await ToggleServerAsync(); + _port.ValueChanged += async (_, _) => await ChangePortAsync(); + _ftp.CheckedChanged += async (_, _) => await ToggleFtpAsync(); + _ftpPort.ValueChanged += async (_, _) => await ChangeFtpPortAsync(); + _shares.DrawItem += DrawShare; + _shares.DoubleClick += (_, _) => EditSelected(); + _shares.SelectedIndexChanged += (_, _) => UpdateButtons(); + _shares.MouseDown += (_, e) => + { + if (e.Button == MouseButtons.Right) + _shares.SelectedIndex = _shares.IndexFromPoint(e.Location); + }; + _shares.KeyDown += (_, e) => + { + if (e.KeyCode == Keys.Delete) RemoveSelected(); + if (e.KeyCode == Keys.Enter) EditSelected(); + if (e.KeyCode == Keys.Insert) AddShare(null); + if (e.KeyCode == Keys.Space) _ = ToggleSelectedAsync(); + }; + _shares.ContextMenuStrip = _menu; + _menu.Opening += (_, _) => BuildMenu(); + _add.Click += (_, _) => AddShare(null); + _remove.Click += (_, _) => RemoveSelected(); + _edit.Click += (_, _) => EditSelected(); + _users.Click += (_, _) => ShowUsers(); + _explore.Click += (_, _) => UiPlacement.ShowCentered(new ExplorerForm { Icon = Icon }, this); + _info.Click += (_, _) => ShowInfo(); + _ok.Click += (_, _) => WindowState = FormWindowState.Minimized; + + AllowDrop = true; + foreach (Control target in new Control[] { this, _shares }) + { + target.DragEnter += (_, e) => e.Effect = e.Data?.GetDataPresent(DataFormats.FileDrop) == true ? DragDropEffects.Copy : DragDropEffects.None; + target.DragDrop += (_, e) => + { + if (e.Data?.GetData(DataFormats.FileDrop) is string[] paths) + foreach (var path in paths) + AddShare(Directory.Exists(path) ? path : Path.GetDirectoryName(path)); + }; + } + + BuildTray(); + _host.Changed += () => SafeInvoke(RefreshAll); + } + + private static Icon? LoadIcon() + { + using var stream = typeof(MainForm).Assembly.GetManifestResourceStream("sunfish.ico"); + return stream is null ? null : new Icon(stream); + } + + private void BuildTray() + { + _tray.Icon = Icon; + _tray.Text = "Sunfish"; + var menu = new ContextMenuStrip(); + menu.Items.Add(Texts.Restore, null, (_, _) => RestoreFromTray()); + menu.Items.Add(Texts.Exit, null, (_, _) => Close()); + _tray.ContextMenuStrip = menu; + _tray.DoubleClick += (_, _) => RestoreFromTray(); + Resize += (_, _) => + { + if (WindowState == FormWindowState.Minimized) + { + Hide(); + _tray.Visible = true; + } + }; + } + + private void RestoreFromTray() + { + Show(); + WindowState = FormWindowState.Normal; + _tray.Visible = false; + Activate(); + } + + protected override void OnShown(EventArgs e) + { + base.OnShown(e); + try + { + _host.Start(); + } + catch (SocketException ex) + { + _host.Log.Error(Strings.CannotListen(_host.Config.Server.HttpPort, ex.Message)); + MessageBox.Show(this, Texts.CannotStart(ex.Message), Text, MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + RefreshAll(); + foreach (var folder in _pendingFolders) + AddShare(folder); + _pendingFolders.Clear(); + } + + protected override void OnFormClosed(FormClosedEventArgs e) + { + _tray.Visible = false; + _tray.Dispose(); + base.OnFormClosed(e); + } + + /// Otra instancia (o arrastrar sobre el .exe con la ventana abierta) trae carpetas. + public void ReceiveFromOtherInstance(List folders) + { + RestoreFromTray(); + foreach (var folder in folders) + AddShare(folder); + } + + // ------------------------------------------------------------------ estado + + private bool IsQuick => _host.Config.FilePath is null; + + private void RefreshAll() + { + _updating = true; + try + { + Text = IsQuick ? Texts.QuickTitle : "Sunfish"; + _rootMenu.Checked = _host.Config.Server.RootMenu; + // Cada casilla es el interruptor del servicio; en rojo si deberia escuchar y no puede + // (puerto ocupado). + var server = _host.Config.Server; + _listen.Checked = server.Http; + _listen.ForeColor = server.Http && !_host.HttpRunning ? Color.Firebrick : SystemColors.ControlText; + _port.Value = Math.Clamp(server.HttpPort, 1, 65535); + _ftp.Checked = server.Ftp; + _ftp.ForeColor = server.Ftp && !_host.FtpRunning ? Color.Firebrick : SystemColors.ControlText; + _ftpPort.Value = Math.Clamp(server.FtpPort, 1, 65535); + string? selected = SelectedShare()?.Name; + _shares.BeginUpdate(); + _shares.Items.Clear(); + foreach (var s in _host.Config.Shares) + _shares.Items.Add(s.Name); + _shares.SelectedIndex = selected is null ? -1 : _shares.Items.IndexOf(selected); + _shares.EndUpdate(); + _tray.Text = "Sunfish" + (_host.HttpRunning ? $" http:{_host.HttpPort}" : "") + (_host.FtpRunning ? $" ftp:{_host.FtpPort}" : ""); + UpdateButtons(); + } + finally + { + _updating = false; + } + } + + private void UpdateButtons() + { + bool any = SelectedShare() is not null; + _remove.Enabled = any; + _edit.Enabled = any; + } + + /// Como en la 2.0: icono de carpeta (gris si esta desactivado), nombre y debajo, en pequeno, la location. + private void DrawShare(object? sender, DrawItemEventArgs e) + { + e.DrawBackground(); + if (e.Index < 0 || e.Index >= _shares.Items.Count) + return; + var share = _host.Config.Shares.FirstOrDefault(s => s.Name == (string)_shares.Items[e.Index]); + if (share is null) + return; + bool selected = (e.State & DrawItemState.Selected) != 0; + var g = e.Graphics; + int icon = UiImages.Px(24); + int top = e.Bounds.Top + (e.Bounds.Height - icon) / 2; + g.DrawImage(UiImages.Get(share.Enabled ? "foldericon" : "foldericond", 24), e.Bounds.Left + UiImages.Px(3), top, icon, icon); + int x = e.Bounds.Left + icon + UiImages.Px(8); + var main = selected ? SystemColors.HighlightText : share.Enabled ? SystemColors.WindowText : SystemColors.GrayText; + var sub = selected ? SystemColors.HighlightText : SystemColors.GrayText; + int lineHeight = Font.Height; + TextRenderer.DrawText(g, share.Name, Font, new Point(x, e.Bounds.Top + UiImages.Px(1)), main); + string detail = share.Location + " " + Permissions(share) + (share.Users.Count > 0 ? " " + string.Join(", ", share.Users) : ""); + TextRenderer.DrawText(g, detail, _small, new Point(x + UiImages.Px(4), e.Bounds.Top + lineHeight), sub); + if (share.Upload) + { + int badge = UiImages.Px(12); + g.DrawImage(UiImages.Get("upload", 12), e.Bounds.Left + UiImages.Px(1), e.Bounds.Bottom - badge - UiImages.Px(1), badge, badge); + } + e.DrawFocusRectangle(); + } + + private static string Permissions(ShareConfig s) => + (s.Listing ? "L" : "·") + (s.Upload ? "U" : "·") + (s.Overwrite ? "O" : "·") + + (s.Rename ? "R" : "·") + (s.Delete ? "D" : "·") + (s.Zip ? "Z" : "·"); + + private void SafeInvoke(Action action) + { + if (IsDisposed || !IsHandleCreated) + return; + try + { + BeginInvoke(action); + } + catch (InvalidOperationException) { } + } + + // ------------------------------------------------------------------ acciones + + private async Task ToggleServerAsync() + { + if (_updating || _listen.Checked == _host.Config.Server.Http) + return; + bool on = _listen.Checked; + await ChangeAsync(c => c.Server.Http = on); + } + + private async Task ToggleFtpAsync() + { + if (_updating || _ftp.Checked == _host.Config.Server.Ftp) + return; + bool on = _ftp.Checked; + await ChangeAsync(c => c.Server.Ftp = on); + } + + private async Task ChangeFtpPortAsync() + { + if (_updating || (int)_ftpPort.Value == _host.Config.Server.FtpPort) + return; + int port = (int)_ftpPort.Value; + await ChangeAsync(c => c.Server.FtpPort = port); + } + + private async Task ChangePortAsync() + { + if (_updating || (int)_port.Value == _host.Config.Server.HttpPort) + return; + int port = (int)_port.Value; + await ChangeAsync(c => c.Server.HttpPort = port); + } + + private async Task ChangeRootMenuAsync() + { + if (_updating || _rootMenu.Checked == _host.Config.Server.RootMenu) + return; + bool value = _rootMenu.Checked; + await ChangeAsync(c => c.Server.RootMenu = value); + } + + private ShareConfig? SelectedShare() => + _shares.SelectedItem is string name ? _host.Config.Shares.FirstOrDefault(s => s.Name == name) : null; + + private async Task ToggleSelectedAsync() + { + if (SelectedShare() is not { } share) + return; + var copy = share.Clone(); + copy.Enabled = !share.Enabled; + if (ShareSettings.Validate(_host.Config, copy, share.Name) is { } problem) + { + MessageBox.Show(this, problem, Text, MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + await ChangeAsync(c => c.Shares.First(s => s.Name == share.Name).Enabled = copy.Enabled); + } + + private void AddShare(string? folder) + { + var share = new ShareConfig(); + if (folder is not null && Directory.Exists(folder)) + { + string full = Path.GetFullPath(folder); + string name = Path.GetFileName(Path.TrimEndingDirectorySeparator(full)); + if (name.Length == 0) + name = "share"; + share.Name = ShareSettings.SuggestName(_host.Config, name); + share.Location = ShareSettings.SuggestLocation(_host.Config, share.Name); + share.Path = full; + } + using var dialog = new ShareDialog(share, _host.Config, null); + if (dialog.ShowDialog(this) == DialogResult.OK) + _ = ChangeAsync(c => c.Shares.Add(dialog.Result)); + } + + private void EditSelected() + { + if (SelectedShare() is not { } share) + return; + using var dialog = new ShareDialog(share.Clone(), _host.Config, share.Name); + if (dialog.ShowDialog(this) == DialogResult.OK) + { + _ = ChangeAsync(c => + { + int index = c.Shares.FindIndex(s => s.Name == share.Name); + if (index >= 0) + c.Shares[index] = dialog.Result; + }); + } + } + + private void RemoveSelected() + { + if (SelectedShare() is not { } share) + return; + if (MessageBox.Show(this, Strings.AskDeleteShare(share.Name), Text, MessageBoxButtons.YesNo, MessageBoxIcon.Question) == DialogResult.Yes) + _ = ChangeAsync(c => c.Shares.RemoveAll(s => s.Name == share.Name)); + } + + private void ShowUsers() + { + using var dialog = new UsersDialog(_host.Config.Clone()); + if (dialog.ShowDialog(this) == DialogResult.OK && dialog.Changed) + _ = ChangeAsync(c => + { + c.Users.Clear(); + c.Users.AddRange(dialog.Config.Users); + foreach (var s in c.Shares) + s.Users.RemoveAll(u => !c.Users.Any(x => x.Name.Equals(u, StringComparison.OrdinalIgnoreCase))); + }); + } + + private void ShowInfo() + { + if (_infoForm is null || _infoForm.IsDisposed) + { + _infoForm = new InfoForm(_host) { Icon = Icon }; + UiPlacement.ShowCentered(_infoForm, this); + } + else + { + _infoForm.Activate(); + } + } + + private void BuildMenu() + { + _menu.Items.Clear(); + if (SelectedShare() is not { } share) + { + _menu.Items.Add(Texts.Add.Replace("&", ""), UiImages.Get("plus"), (_, _) => AddShare(null)); + return; + } + var addresses = LocalAddresses.Get(); + if (addresses.Count > 0 && (_host.HttpRunning || _host.FtpRunning)) + { + if (_host.HttpRunning) + { + string first = LocalAddresses.HttpUrl(addresses[0].Address, _host.HttpPort, share); + _menu.Items.Add(Texts.OpenInBrowser, UiImages.Get("share"), (_, _) => Open(first)); + } + var copy = new ToolStripMenuItem(Texts.CopyUrl); + foreach (var address in addresses) + { + var urls = new List(); + if (_host.HttpRunning) + urls.Add(LocalAddresses.HttpUrl(address.Address, _host.HttpPort, share)); + if (_host.FtpRunning && share.Ftp) + urls.Add(LocalAddresses.FtpUrl(address.Address, _host.FtpPort, share)); + foreach (var url in urls) + copy.DropDownItems.Add($"{url} ({address.Interface})", null, (_, _) => Clipboard.SetText(url)); + } + _menu.Items.Add(copy); + } + _menu.Items.Add(Texts.OpenFolder, UiImages.Get("foldericon"), (_, _) => Open(share.Path)); + _menu.Items.Add(new ToolStripSeparator()); + var edit = new ToolStripMenuItem(Texts.Edit.Replace("&", ""), UiImages.Get("pencil"), (_, _) => EditSelected()); + edit.Font = new Font(edit.Font, FontStyle.Bold); + _menu.Items.Add(edit); + _menu.Items.Add(share.Enabled ? Texts.Disable : Texts.Enable, UiImages.Get(share.Enabled ? "foldericond" : "foldericon"), async (_, _) => await ToggleSelectedAsync()); + _menu.Items.Add(Texts.Remove.Replace("&", ""), UiImages.Get("delete"), (_, _) => RemoveSelected()); + } + + internal static void Open(string? target) + { + if (string.IsNullOrEmpty(target)) + return; + try + { + Process.Start(new ProcessStartInfo(target) { UseShellExecute = true }); + } + catch (Exception e) when (e is System.ComponentModel.Win32Exception or InvalidOperationException) { } + } + + /// Cambia una copia de la configuracion, la guarda (si hay fichero) y la aplica. + private async Task ChangeAsync(Action change) + { + var copy = _host.Config.Clone(); + change(copy); + bool ok = IsQuick ? await _host.ApplyAsync(copy) : await _host.SaveAndApplyAsync(copy); + if (!ok) + MessageBox.Show(this, Texts.CannotStart(Strings.CannotListen(copy.Server.HttpPort, "")), Text, MessageBoxButtons.OK, MessageBoxIcon.Warning); + RefreshAll(); + } +} diff --git a/src/Sunfish.WinForms/Program.cs b/src/Sunfish.WinForms/Program.cs new file mode 100644 index 0000000..ccde56e --- /dev/null +++ b/src/Sunfish.WinForms/Program.cs @@ -0,0 +1,140 @@ +using System.IO.Pipes; +using System.Text; +using Sunfish.Config; +using Sunfish.Host; + +namespace Sunfish.WinForms; + +internal static class Program +{ + private const string MutexName = "Local\\Sunfish3-Gui"; + private const string PipeName = "Sunfish3-Gui"; + + /// + /// Sunfish.exe [CARPETA...] [-c FICHERO] [--lang es|en] + /// Una carpeta como argumento (tambien arrastrandola sobre el .exe) se comparte al momento. + /// Si ya hay una ventana abierta, se le pasan las carpetas y esta instancia termina. + /// + [STAThread] + private static int Main(string[] args) + { + string? configPath = null; + var folders = new List(); + for (int i = 0; i < args.Length; i++) + { + if (args[i] is "-c" or "--config" && i + 1 < args.Length) + configPath = args[++i]; + else if (args[i] == "--lang" && i + 1 < args.Length) + Strings.Force(args[++i]); + else + folders.Add(Path.GetFullPath(args[i])); + } + + using var mutex = new Mutex(true, MutexName, out bool first); + if (!first) + { + SendToRunningInstance(folders); + return 0; + } + + ApplicationConfiguration.Initialize(); + + SunfishConfig config; + string? found = ConfigLocator.Find(configPath); + if (folders.Count > 0 && (found is null || !File.Exists(found))) + { + // Modo rapido: las carpetas sin fichero de configuracion; no se guarda nada. + config = new SunfishConfig(); + foreach (var folder in folders.Where(Directory.Exists)) + { + string name = ShareSettings.SuggestName(config, Path.GetFileName(Path.TrimEndingDirectorySeparator(folder))); + config.Shares.Add(new ShareConfig + { + Name = name, + Location = config.Shares.Count == 0 ? "/" : ShareSettings.SuggestLocation(config, name), + Path = folder, + }); + } + folders.Clear(); + } + else if (found is not null && File.Exists(found)) + { + config = SunfishConfig.Load(found); + } + else + { + var answer = MessageBox.Show(Texts.CreateConfigQuestion(ConfigLocator.PortablePath, ConfigLocator.UserPath), + "Sunfish", MessageBoxButtons.YesNoCancel, MessageBoxIcon.Question); + if (answer == DialogResult.Cancel) + return 1; + string created = answer == DialogResult.Yes ? ConfigLocator.PortablePath : ConfigLocator.UserPath; + config = new SunfishConfig(); + // Si hay una configuracion de la 2.0 a mano, se ofrece traer sus shares. + if (LegacyImport.Find() is { } legacy && + MessageBox.Show(Strings.ImportQuestion(legacy), "Sunfish", MessageBoxButtons.YesNo, MessageBoxIcon.Question) == DialogResult.Yes) + { + var notes = new List(); + if (LegacyImport.FromV2(File.ReadAllText(legacy), notes) is { } imported) + { + int count = LegacyImport.Merge(config, imported, isNew: true); + MessageBox.Show(string.Join("\n", notes.Prepend(Strings.ImportDone(count, created)).Select((n, i) => i == 0 ? n : "• " + n)), + "Sunfish", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + else + { + MessageBox.Show(Strings.ImportInvalid(legacy), "Sunfish", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + } + config.Save(created); + } + + var host = new SunfishHost(config); + foreach (var error in config.Errors) + host.Log.Warning(Strings.ConfigReloadError(error)); + host.WatchConfigFile(); + + using var form = new MainForm(host, folders); + _ = ListenForOtherInstancesAsync(form); + Application.Run(form); + host.DisposeAsync().AsTask().GetAwaiter().GetResult(); + return 0; + } + + private static void SendToRunningInstance(List folders) + { + try + { + using var pipe = new NamedPipeClientStream(".", PipeName, PipeDirection.Out); + pipe.Connect(2000); + var data = Encoding.UTF8.GetBytes(string.Join('\n', folders) + "\n"); + pipe.Write(data, 0, data.Length); + } + catch (Exception e) when (e is IOException or TimeoutException) { } + } + + /// Cada conexion trae carpetas (una por linea); sin carpetas solo se trae la ventana al frente. + private static async Task ListenForOtherInstancesAsync(MainForm form) + { + while (!form.IsDisposed) + { + try + { + await using var pipe = new NamedPipeServerStream(PipeName, PipeDirection.In, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous); + await pipe.WaitForConnectionAsync(); + using var reader = new StreamReader(pipe, Encoding.UTF8); + string text = await reader.ReadToEndAsync(); + var folders = text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); + if (!form.IsDisposed) + form.BeginInvoke(() => form.ReceiveFromOtherInstance(folders)); + } + catch (IOException) + { + await Task.Delay(500); + } + catch (ObjectDisposedException) + { + return; + } + } + } +} diff --git a/src/Sunfish.WinForms/QrForm.cs b/src/Sunfish.WinForms/QrForm.cs new file mode 100644 index 0000000..349ebbd --- /dev/null +++ b/src/Sunfish.WinForms/QrForm.cs @@ -0,0 +1,57 @@ +using Sunfish.Qr; + +namespace Sunfish.WinForms; + +/// Codigo QR de una direccion, para abrirla desde un movil. Pequena y sin redimensionar. +internal sealed class QrForm : Form +{ + public QrForm(string url) + { + SuspendLayout(); + AutoScaleDimensions = new SizeF(96F, 96F); + AutoScaleMode = AutoScaleMode.Dpi; + Text = Texts.QrTitle; + FormBorderStyle = FormBorderStyle.FixedToolWindow; + ShowInTaskbar = false; + BackColor = Color.White; + AutoSize = true; + AutoSizeMode = AutoSizeMode.GrowAndShrink; + Padding = new Padding(6); + + var qr = QrCode.Encode(url); + int scale = Math.Max(3, UiImages.Px(5)); + var picture = new PictureBox + { + Image = Render(qr, scale), SizeMode = PictureBoxSizeMode.AutoSize, + Location = new Point(Padding.Left, Padding.Top), Margin = new Padding(0), + }; + var label = new Label + { + Text = url, AutoSize = true, MaximumSize = new Size(picture.Width, 0), ForeColor = Color.Black, + Location = new Point(Padding.Left, picture.Bottom + 4), Margin = new Padding(0), + }; + Controls.AddRange([picture, label]); + ResumeLayout(false); + PerformLayout(); + KeyPreview = true; + KeyDown += (_, e) => + { + if (e.KeyCode == Keys.Escape) + Close(); + }; + } + + /// Modulos negros sobre blanco con el margen de la norma; pixeles exactos, sin suavizar. + private static Bitmap Render(QrCode qr, int scale) + { + int size = (qr.Size + QrImage.QuietZone * 2) * scale; + var bitmap = new Bitmap(size, size); + using var g = Graphics.FromImage(bitmap); + g.Clear(Color.White); + for (int y = 0; y < qr.Size; y++) + for (int x = 0; x < qr.Size; x++) + if (qr[x, y]) + g.FillRectangle(Brushes.Black, (x + QrImage.QuietZone) * scale, (y + QrImage.QuietZone) * scale, scale, scale); + return bitmap; + } +} diff --git a/src/Sunfish.WinForms/Resources/delete.png b/src/Sunfish.WinForms/Resources/delete.png new file mode 100644 index 0000000..5d7169b --- /dev/null +++ b/src/Sunfish.WinForms/Resources/delete.png Binary files differ diff --git a/src/Sunfish.WinForms/Resources/download.png b/src/Sunfish.WinForms/Resources/download.png new file mode 100644 index 0000000..90ac837 --- /dev/null +++ b/src/Sunfish.WinForms/Resources/download.png Binary files differ diff --git a/src/Sunfish.WinForms/Resources/file.png b/src/Sunfish.WinForms/Resources/file.png new file mode 100644 index 0000000..81323f7 --- /dev/null +++ b/src/Sunfish.WinForms/Resources/file.png Binary files differ diff --git a/src/Sunfish.WinForms/Resources/foldericon.png b/src/Sunfish.WinForms/Resources/foldericon.png new file mode 100644 index 0000000..16651d9 --- /dev/null +++ b/src/Sunfish.WinForms/Resources/foldericon.png Binary files differ diff --git a/src/Sunfish.WinForms/Resources/foldericond.png b/src/Sunfish.WinForms/Resources/foldericond.png new file mode 100644 index 0000000..360b602 --- /dev/null +++ b/src/Sunfish.WinForms/Resources/foldericond.png Binary files differ diff --git a/src/Sunfish.WinForms/Resources/info.png b/src/Sunfish.WinForms/Resources/info.png new file mode 100644 index 0000000..2967e4d --- /dev/null +++ b/src/Sunfish.WinForms/Resources/info.png Binary files differ diff --git a/src/Sunfish.WinForms/Resources/minus.png b/src/Sunfish.WinForms/Resources/minus.png new file mode 100644 index 0000000..e75c616 --- /dev/null +++ b/src/Sunfish.WinForms/Resources/minus.png Binary files differ diff --git a/src/Sunfish.WinForms/Resources/pencil.png b/src/Sunfish.WinForms/Resources/pencil.png new file mode 100644 index 0000000..6966739 --- /dev/null +++ b/src/Sunfish.WinForms/Resources/pencil.png Binary files differ diff --git a/src/Sunfish.WinForms/Resources/plus.png b/src/Sunfish.WinForms/Resources/plus.png new file mode 100644 index 0000000..8b5bae9 --- /dev/null +++ b/src/Sunfish.WinForms/Resources/plus.png Binary files differ diff --git a/src/Sunfish.WinForms/Resources/share.png b/src/Sunfish.WinForms/Resources/share.png new file mode 100644 index 0000000..3dd66cc --- /dev/null +++ b/src/Sunfish.WinForms/Resources/share.png Binary files differ diff --git a/src/Sunfish.WinForms/Resources/up.png b/src/Sunfish.WinForms/Resources/up.png new file mode 100644 index 0000000..75943da --- /dev/null +++ b/src/Sunfish.WinForms/Resources/up.png Binary files differ diff --git a/src/Sunfish.WinForms/Resources/upload.png b/src/Sunfish.WinForms/Resources/upload.png new file mode 100644 index 0000000..5b34e06 --- /dev/null +++ b/src/Sunfish.WinForms/Resources/upload.png Binary files differ diff --git a/src/Sunfish.WinForms/Resources/users.png b/src/Sunfish.WinForms/Resources/users.png new file mode 100644 index 0000000..2613d74 --- /dev/null +++ b/src/Sunfish.WinForms/Resources/users.png Binary files differ diff --git a/src/Sunfish.WinForms/ShareDialog.cs b/src/Sunfish.WinForms/ShareDialog.cs new file mode 100644 index 0000000..f82bef8 --- /dev/null +++ b/src/Sunfish.WinForms/ShareDialog.cs @@ -0,0 +1,180 @@ +using Sunfish.Config; + +namespace Sunfish.WinForms; + +/// +/// Alta o edicion de un share con el aspecto de la 2.0: estrecho y vertical, etiqueta encima de +/// cada campo, los obligatorios en amarillo claro y el aviso en un recuadro amarillo. +/// +internal sealed class ShareDialog : Form +{ + private static readonly Color MandatoryBack = Color.FromArgb(255, 252, 220); + private const int FieldWidth = 260; + + private readonly SunfishConfig _config; + private readonly string? _originalName; + private readonly TextBox _name = new() { Width = FieldWidth - 70, BackColor = MandatoryBack }; + private readonly CheckBox _enabled = new() { AutoSize = true, Margin = new Padding(8, 4, 0, 0) }; + private readonly TextBox _location = new() { Width = FieldWidth, BackColor = MandatoryBack }; + private readonly TextBox _path = new() { Width = FieldWidth - 30, BackColor = MandatoryBack }; + private readonly TextBox _index = new() { Width = FieldWidth }; + private readonly CheckBox _listing = new() { AutoSize = true }; + private readonly CheckBox _upload = new() { AutoSize = true }; + private readonly CheckBox _overwrite = new() { AutoSize = true }; + private readonly CheckBox _rename = new() { AutoSize = true }; + private readonly CheckBox _delete = new() { AutoSize = true }; + private readonly CheckBox _zip = new() { AutoSize = true }; + private readonly CheckedListBox _users = new() { Width = FieldWidth, Height = 64, CheckOnClick = true, IntegralHeight = false }; + private readonly Label _warning = new() + { + AutoSize = true, MaximumSize = new Size(FieldWidth, 0), BackColor = Color.FromArgb(240, 230, 160), + BorderStyle = BorderStyle.FixedSingle, Padding = new Padding(3), Margin = new Padding(3, 8, 3, 3), + }; + + public ShareConfig Result { get; } + + public ShareDialog(ShareConfig share, SunfishConfig config, string? originalName) + { + Result = share; + _config = config; + _originalName = originalName; + + SuspendLayout(); + AutoScaleDimensions = new SizeF(96F, 96F); + AutoScaleMode = AutoScaleMode.Dpi; + Text = originalName is null ? Texts.NewShare : Texts.EditShareTitle(originalName); + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = MinimizeBox = false; + ShowInTaskbar = false; + StartPosition = FormStartPosition.CenterParent; + AutoSize = true; + AutoSizeMode = AutoSizeMode.GrowAndShrink; + Padding = new Padding(8, 6, 8, 8); + + _enabled.Text = Texts.Active; + _listing.Text = Strings.OptListing; + _upload.Text = Strings.OptUpload; + _overwrite.Text = Strings.OptOverwrite; + _rename.Text = Strings.OptRename; + _delete.Text = Strings.OptDelete; + _zip.Text = Strings.OptZip; + + _name.Text = share.Name; + _location.Text = share.Location; + _path.Text = share.Path; + _index.Text = share.Index; + _enabled.Checked = share.Enabled; + _listing.Checked = share.Listing; + _upload.Checked = share.Upload; + _overwrite.Checked = share.Overwrite; + _rename.Checked = share.Rename; + _delete.Checked = share.Delete; + _zip.Checked = share.Zip; + foreach (var user in config.Users) + _users.Items.Add(user.Name, share.Users.Contains(user.Name, StringComparer.OrdinalIgnoreCase)); + + var browse = new Button { Text = "...", Size = new Size(26, _path.Height + 2), Margin = new Padding(2, 2, 0, 0), UseVisualStyleBackColor = true }; + browse.Click += (_, _) => Browse(); + + // El formulario se ajusta al contenido: el panel va en la esquina del relleno (Padding solo + // lo respetan los controles acoplados) y el relleno derecho/inferior lo anade AutoSize. + var main = new FlowLayoutPanel + { + FlowDirection = FlowDirection.TopDown, AutoSize = true, WrapContents = false, + Location = new Point(Padding.Left, Padding.Top), Margin = new Padding(0), + }; + main.Controls.Add(Caption(Strings.FieldName)); + main.Controls.Add(Row(_name, _enabled)); + main.Controls.Add(Caption(Strings.FieldLocation)); + main.Controls.Add(_location); + main.Controls.Add(Caption(Strings.FieldPath)); + main.Controls.Add(Row(_path, browse)); + main.Controls.Add(Caption(Strings.FieldIndex)); + main.Controls.Add(_index); + main.Controls.Add(new Panel { Height = 6, Width = 1 }); + foreach (var check in new[] { _listing, _upload, _overwrite, _rename, _delete, _zip }) + { + check.Margin = new Padding(14, 2, 3, 2); + main.Controls.Add(check); + } + if (config.Users.Count > 0) + { + main.Controls.Add(Caption(Texts.AllowedUsers)); + main.Controls.Add(_users); + } + main.Controls.Add(_warning); + + var ok = new Button { Text = Texts.Ok, Size = new Size(80, 25), UseVisualStyleBackColor = true }; + ok.Click += (_, _) => Accept(); + var cancel = new Button { Text = Texts.Cancel, Size = new Size(80, 25), DialogResult = DialogResult.Cancel, UseVisualStyleBackColor = true }; + var buttons = new FlowLayoutPanel { FlowDirection = FlowDirection.RightToLeft, Width = FieldWidth + 6, Height = 25, Margin = new Padding(0, 10, 0, 0) }; + ok.Margin = new Padding(3, 0, 3, 0); + cancel.Margin = new Padding(3, 0, 3, 0); + buttons.Controls.AddRange([cancel, ok]); + main.Controls.Add(buttons); + AcceptButton = ok; + CancelButton = cancel; + + Controls.Add(main); + ResumeLayout(false); + PerformLayout(); + + _upload.CheckedChanged += (_, _) => UpdateWarning(); + _listing.CheckedChanged += (_, _) => UpdateWarning(); + UpdateWarning(); + } + + private static Label Caption(string text) => + new() { Text = text + ":", AutoSize = true, Margin = new Padding(3, 6, 3, 1) }; + + private static FlowLayoutPanel Row(params Control[] controls) + { + var row = new FlowLayoutPanel { AutoSize = true, WrapContents = false, Margin = new Padding(0) }; + row.Controls.AddRange(controls); + return row; + } + + private void Browse() + { + using var picker = new FolderBrowserDialog { SelectedPath = _path.Text, UseDescriptionForTitle = true, Description = Strings.FieldPath }; + if (picker.ShowDialog(this) != DialogResult.OK) + return; + _path.Text = picker.SelectedPath; + if (_name.Text.Length == 0) + { + _name.Text = ShareSettings.SuggestName(_config, Path.GetFileName(Path.TrimEndingDirectorySeparator(picker.SelectedPath))); + _location.Text = ShareSettings.SuggestLocation(_config, _name.Text); + } + } + + private void UpdateWarning() + { + bool show = _upload.Checked && _listing.Checked; + _warning.Text = show ? Strings.WarnUploadListing : ""; + _warning.Visible = show; + } + + private void Accept() + { + var s = Result; + s.Name = _name.Text.Trim(); + s.Location = _location.Text.Trim(); + s.Path = _path.Text.Trim(); + s.Index = _index.Text.Trim(); + s.Enabled = _enabled.Checked; + s.Listing = _listing.Checked; + s.Upload = _upload.Checked; + s.Overwrite = _overwrite.Checked; + s.Rename = _rename.Checked; + s.Delete = _delete.Checked; + s.Zip = _zip.Checked; + if (_config.Users.Count > 0) + s.Users = _users.CheckedItems.Cast().ToList(); + if (ShareSettings.Validate(_config, s, _originalName) is { } problem) + { + MessageBox.Show(this, problem, Text, MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + DialogResult = DialogResult.OK; + } +} diff --git a/src/Sunfish.WinForms/Sunfish.WinForms.csproj b/src/Sunfish.WinForms/Sunfish.WinForms.csproj new file mode 100644 index 0000000..905a98c --- /dev/null +++ b/src/Sunfish.WinForms/Sunfish.WinForms.csproj @@ -0,0 +1,36 @@ + + + + WinExe + net10.0-windows + enable + enable + true + Sunfish + Sunfish.WinForms + 3.0.0 + XWolfOverride + ..\..\art\sunfish.ico + PerMonitorV2 + true + + + true + true + true + true + embedded + + + + + + + + + + + + + diff --git a/src/Sunfish.WinForms/Texts.cs b/src/Sunfish.WinForms/Texts.cs new file mode 100644 index 0000000..c02ad09 --- /dev/null +++ b/src/Sunfish.WinForms/Texts.cs @@ -0,0 +1,91 @@ +namespace Sunfish.WinForms; + +/// Textos propios de la ventana (el idioma lo decide Sunfish.Strings). +internal static class Texts +{ + private static string T(string english, string spanish) => Strings.Spanish ? spanish : english; + + public static string ServerOn => T("Server running", "Servidor en marcha"); + public static string RootMenu => T("Root menu", "Menú raíz"); + public static string Explore => T("Explore another server", "Explorar otro servidor"); + public static string ExplorerTitle => T("Sunfish - explore", "Sunfish - explorar"); + public static string ExplorerAddressTip => T("Servers found on the network, or type an address (host:port)", "Servidores encontrados en la red, o escribe una dirección (equipo:puerto)"); + public static string ExplorerGo => T("Connect", "Conectar"); + public static string ExplorerUp => T("Parent folder", "Carpeta superior"); + public static string ExplorerDownload => T("Download to this computer", "Descargar a este equipo"); + public static string ExplorerUpload => T("Upload files (or drop them on the list)", "Subir ficheros (o suéltalos en la lista)"); + public static string ExplorerNewFolder => T("New folder", "Nueva carpeta"); + public static string ExplorerDelete => T("Delete", "Borrar"); + public static string ExplorerDownloadTo => T("Download to", "Descargar en"); + public static string ExplorerSearching => T("Searching the network...", "Buscando en la red..."); + public static string ExplorerNoneFound => T("No servers found; type an address.", "No se han encontrado servidores; escribe una dirección."); + public static string ExplorerFound(int n) => T($"{n} server(s) found.", $"{n} servidor(es) encontrado(s)."); + public static string ExplorerBadAddress => T("Invalid address.", "Dirección no válida."); + public static string ExplorerItems(int n) => T($"{n} item(s)", $"{n} elemento(s)"); + public static string ExplorerDone => T("Done.", "Hecho."); + public static string ExplorerTransferred(string size) => T($"Transferred {size}...", $"Transferido {size}..."); + public static string ExplorerLogin(string server) => T($"User for {server}", $"Usuario para {server}"); + public static string ExplorerDeleteOne(string name) => T($"Delete '{name}' on the other server?", $"¿Borrar '{name}' en el otro servidor?"); + public static string ExplorerDeleteMany(int n) => T($"Delete {n} items on the other server?", $"¿Borrar {n} elementos en el otro servidor?"); + public static string ListenOnPort => T("Web on port", "Web en puerto"); + public static string FtpOnPort => T("FTP on port", "FTP en puerto"); + public static string ListenTip => T("Serve the shares by web (HTTP)", "Servir los shares por web (HTTP)"); + public static string FtpTip => T("Serve the shares by FTP", "Servir los shares por FTP"); + public static string Info => T("Addresses, log and about", "Direcciones, registro y acerca de"); + public static string Hide => T("Ok", "Ok"); + public static string HideTip => T("Keep serving in the notification area", "Seguir sirviendo desde el área de notificación"); + public static string Enable => T("Enable", "Activar"); + public static string Disable => T("Disable", "Desactivar"); + public static string QuickTitle => T("Sunfish - quick share", "Sunfish - compartido al momento"); + public static string About => "Sunfish 3.0 (C) 2007-2026 XWolfOverride · MIT"; + public static string Active => T("Active", "Activo"); + public static string Mandatory => T("Required", "Obligatorio"); + public static string Add => T("&Add...", "&Añadir..."); + public static string Edit => T("&Edit...", "&Editar..."); + public static string Remove => T("&Remove", "&Quitar"); + public static string Users => T("&Users...", "&Usuarios..."); + public static string OpenInBrowser => T("Open in the browser", "Abrir en el navegador"); + public static string CopyUrl => T("Copy address", "Copiar dirección"); + public static string OpenFolder => T("Open the folder", "Abrir la carpeta"); + public static string Restore => T("Open Sunfish", "Abrir Sunfish"); + public static string Exit => T("Exit", "Salir"); + public static string Ok => T("OK", "Aceptar"); + public static string Cancel => T("Cancel", "Cancelar"); + public static string Close => T("Close", "Cerrar"); + public static string Browse => T("...", "..."); + public static string NewShare => T("New share", "Nuevo share"); + public static string EditShareTitle(string name) => T($"Share '{name}'", $"Share '{name}'"); + public static string Permissions => T("Permissions", "Permisos"); + public static string AllowedUsers => T("Users (none checked: public)", "Usuarios (ninguno marcado: público)"); + public static string ColName => T("Name", "Nombre"); + public static string ColLocation => T("Location", "Location"); + public static string ColFolder => T("Folder", "Carpeta"); + public static string ColPermissions => T("Permissions", "Permisos"); + public static string ColUsers => T("Users", "Usuarios"); + public static string UsersTitle => T("Users", "Usuarios"); + public static string Password => T("&Password...", "&Contraseña..."); + public static string Delete => T("&Delete", "&Borrar"); + public static string NewUser => T("New user", "Usuario nuevo"); + public static string PasswordFor(string name) => T($"Password for '{name}'", $"Contraseña de '{name}'"); + public static string UserName => T("Name:", "Nombre:"); + public static string PasswordLabel => T("Password:", "Contraseña:"); + public static string RepeatLabel => T("Repeat:", "Repetir:"); + public static string PasswordMismatch => T("The passwords do not match.", "Las contraseñas no coinciden."); + public static string DropHint => T("Drop folders here to share them", "Arrastra carpetas aquí para compartirlas"); + public static string NoAddress => T("(no network address)", "(sin dirección de red)"); + public static string CreateConfigQuestion(string portable, string user) => T( + $"There is no configuration file yet.\n\nYes: create it next to the program\n{portable}\n\nNo: create it in your user folder\n{user}", + $"Aún no hay fichero de configuración.\n\nSí: crearlo junto al programa\n{portable}\n\nNo: crearlo en tu carpeta de usuario\n{user}"); + public static string CannotStart(string message) => T($"Cannot start the server:\n{message}", $"No se puede arrancar el servidor:\n{message}"); + public static string StatusUrl(string url) => T($"Address: {url}", $"Dirección: {url}"); + public static string QrTitle => T("QR code", "Código QR"); + public static string ShowQr => T("QR code", "Código QR"); + public static string AdminEnable => T("Enable the web panel (user: admin)", "Activar el panel web (usuario: admin)"); + public static string AdminPasswordKeep => T("New password (empty: keep it)", "Contraseña nueva (vacía: no cambia)"); + public static string AdminHelp(string url) => T( + $"Open {url} from an allowed address. The password travels unencrypted.", + $"Abre {url} desde una dirección permitida. La contraseña viaja sin cifrar."); + public static string AdminLink(bool enabled) => T( + $"Web panel: {(enabled ? "enabled" : "disabled")} (change)", + $"Panel web: {(enabled ? "activado" : "desactivado")} (cambiar)"); +} diff --git a/src/Sunfish.WinForms/UiImages.cs b/src/Sunfish.WinForms/UiImages.cs new file mode 100644 index 0000000..a60643a --- /dev/null +++ b/src/Sunfish.WinForms/UiImages.cs @@ -0,0 +1,56 @@ +using System.Drawing.Drawing2D; + +namespace Sunfish.WinForms; + +/// +/// Imagenes de la interfaz (las de la 2.0, en Resources/), escaladas a los PPP de la pantalla +/// para que no queden diminutas en monitores con escalado. +/// +internal static class UiImages +{ + private static readonly Dictionary<(string, int), Image> Cache = new(); + + public static float Scale { get; } = GetScale(); + + private static float GetScale() + { + using var g = Graphics.FromHwnd(IntPtr.Zero); + return Math.Max(1f, g.DpiX / 96f); + } + + public static int Px(int size) => (int)Math.Round(size * Scale); + + /// Imagen "nombre" (sin .png) a pixeles logicos. + public static Image Get(string name, int size = 16) + { + int px = Px(size); + if (Cache.TryGetValue((name, px), out var cached)) + return cached; + using var stream = typeof(UiImages).Assembly.GetManifestResourceStream($"ui/{name}.png") + ?? throw new InvalidOperationException("Missing image " + name); + using var original = Image.FromStream(stream); + var bitmap = new Bitmap(px, px); + using (var g = Graphics.FromImage(bitmap)) + { + g.InterpolationMode = px % original.Width == 0 ? InterpolationMode.NearestNeighbor : InterpolationMode.HighQualityBicubic; + g.PixelOffsetMode = PixelOffsetMode.Half; + g.DrawImage(original, 0, 0, px, px); + } + Cache[(name, px)] = bitmap; + return bitmap; + } + + /// Boton cuadrado solo con imagen y la descripcion como ayuda emergente. + public static Button IconButton(string image, string tooltip, ToolTip tips) + { + var button = new Button + { + Image = Get(image), + Size = new Size(26, 26), + UseVisualStyleBackColor = true, + AccessibleName = tooltip.Replace("&", ""), + }; + tips.SetToolTip(button, tooltip.Replace("&", "")); + return button; + } +} diff --git a/src/Sunfish.WinForms/UiPlacement.cs b/src/Sunfish.WinForms/UiPlacement.cs new file mode 100644 index 0000000..cf4246b --- /dev/null +++ b/src/Sunfish.WinForms/UiPlacement.cs @@ -0,0 +1,22 @@ +namespace Sunfish.WinForms; + +/// +/// Ventanas no modales (Show) centradas sobre su ventana padre: CenterParent solo funciona con +/// ShowDialog, asi que se colocan a mano y sin salirse del area de trabajo de esa pantalla. +/// +internal static class UiPlacement +{ + public static void ShowCentered(Form form, Form owner) + { + form.StartPosition = FormStartPosition.Manual; + form.Owner = owner; + var area = Screen.FromControl(owner).WorkingArea; + var size = form.Size; + int x = owner.Left + (owner.Width - size.Width) / 2; + int y = owner.Top + (owner.Height - size.Height) / 2; + x = Math.Max(area.Left, Math.Min(x, area.Right - size.Width)); + y = Math.Max(area.Top, Math.Min(y, area.Bottom - size.Height)); + form.Location = new Point(x, y); + form.Show(owner); + } +} diff --git a/src/Sunfish.WinForms/UsersDialog.cs b/src/Sunfish.WinForms/UsersDialog.cs new file mode 100644 index 0000000..3065516 --- /dev/null +++ b/src/Sunfish.WinForms/UsersDialog.cs @@ -0,0 +1,186 @@ +using Sunfish.Auth; +using Sunfish.Config; + +namespace Sunfish.WinForms; + +/// Usuarios y contrasenas (sobre una copia de la configuracion), compacto como la ventana principal. +internal sealed class UsersDialog : Form +{ + private readonly ListBox _list = new() { Dock = DockStyle.Fill, IntegralHeight = false, Margin = new Padding(0, 0, 0, 6) }; + private readonly ToolTip _tips = new(); + + public SunfishConfig Config { get; } + public bool Changed { get; private set; } + + public UsersDialog(SunfishConfig config) + { + Config = config; + SuspendLayout(); + AutoScaleDimensions = new SizeF(96F, 96F); + AutoScaleMode = AutoScaleMode.Dpi; + Text = Texts.UsersTitle; + FormBorderStyle = FormBorderStyle.SizableToolWindow; + ShowInTaskbar = false; + StartPosition = FormStartPosition.CenterParent; + ClientSize = new Size(210, 200); + MinimumSize = new Size(200, 180); + Padding = new Padding(6); + + var add = UiImages.IconButton("plus", Texts.Add, _tips); + var delete = UiImages.IconButton("minus", Texts.Delete, _tips); + var password = UiImages.IconButton("pencil", Texts.Password, _tips); + var close = new Button { Text = Texts.Close, Size = new Size(70, 26), DialogResult = DialogResult.OK, UseVisualStyleBackColor = true }; + AcceptButton = close; + CancelButton = close; + + // Lista arriba; abajo los botones de imagen a la izquierda y Cerrar a la derecha. + var grid = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 5, RowCount = 2, Margin = new Padding(0) }; + for (int i = 0; i < 3; i++) + grid.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + grid.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + grid.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + grid.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + grid.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + grid.Controls.Add(_list, 0, 0); + grid.SetColumnSpan(_list, 5); + add.Margin = delete.Margin = password.Margin = new Padding(0, 0, 3, 0); + close.Margin = new Padding(0); + grid.Controls.Add(add, 0, 1); + grid.Controls.Add(delete, 1, 1); + grid.Controls.Add(password, 2, 1); + grid.Controls.Add(close, 4, 1); + Controls.Add(grid); + ResumeLayout(false); + PerformLayout(); + + add.Click += (_, _) => AddUser(); + password.Click += (_, _) => ChangePassword(); + delete.Click += (_, _) => DeleteUser(); + _list.DoubleClick += (_, _) => ChangePassword(); + Fill(); + } + + private void Fill() + { + _list.Items.Clear(); + foreach (var user in Config.Users) + _list.Items.Add(user.Name); + } + + private void AddUser() + { + using var dialog = new PasswordDialog(Texts.NewUser, askName: true); + if (dialog.ShowDialog(this) != DialogResult.OK) + return; + string name = dialog.UserName; + if (!ShareSettings.IsValidName(name)) + { + MessageBox.Show(this, Strings.ErrorBadName(name), Text, MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + if (Config.Users.Any(u => u.Name.Equals(name, StringComparison.OrdinalIgnoreCase))) + { + MessageBox.Show(this, Strings.ErrorNameInUse(name), Text, MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + Config.Users.Add(new UserConfig { Name = name, PasswordHash = Passwords.Hash(dialog.Password) }); + Changed = true; + Fill(); + } + + private void ChangePassword() + { + if (_list.SelectedItem is not string name) + return; + using var dialog = new PasswordDialog(Texts.PasswordFor(name), askName: false); + if (dialog.ShowDialog(this) != DialogResult.OK) + return; + Config.Users.First(u => u.Name == name).PasswordHash = Passwords.Hash(dialog.Password); + Changed = true; + } + + private void DeleteUser() + { + if (_list.SelectedItem is not string name) + return; + if (MessageBox.Show(this, Strings.AskDeleteUser(name), Text, MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes) + return; + Config.Users.RemoveAll(u => u.Name == name); + Changed = true; + Fill(); + } +} + +/// Pide (opcionalmente un nombre y) una contrasena dos veces; etiquetas encima, como en la 2.0. +internal sealed class PasswordDialog : Form +{ + private readonly TextBox _name = new() { Width = 200, BackColor = Color.FromArgb(255, 252, 220) }; + private readonly TextBox _password = new() { Width = 200, UseSystemPasswordChar = true }; + private readonly TextBox _repeat = new() { Width = 200, UseSystemPasswordChar = true }; + private readonly bool _askName; + + public string UserName => _name.Text.Trim(); + public string Password => _password.Text; + + public PasswordDialog(string title, bool askName, bool askRepeat = true) + { + _askName = askName; + SuspendLayout(); + AutoScaleDimensions = new SizeF(96F, 96F); + AutoScaleMode = AutoScaleMode.Dpi; + Text = title; + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = MinimizeBox = false; + ShowInTaskbar = false; + StartPosition = FormStartPosition.CenterParent; + AutoSize = true; + AutoSizeMode = AutoSizeMode.GrowAndShrink; + Padding = new Padding(10, 6, 10, 10); + + var main = new FlowLayoutPanel { FlowDirection = FlowDirection.TopDown, AutoSize = true, WrapContents = false }; + if (askName) + { + main.Controls.Add(Caption(Texts.UserName)); + main.Controls.Add(_name); + } + main.Controls.Add(Caption(Texts.PasswordLabel)); + main.Controls.Add(_password); + if (askRepeat) + { + main.Controls.Add(Caption(Texts.RepeatLabel)); + main.Controls.Add(_repeat); + } + + var ok = new Button { Text = Texts.Ok, Size = new Size(70, 25), UseVisualStyleBackColor = true }; + ok.Click += (_, _) => Accept(); + var cancel = new Button { Text = Texts.Cancel, Size = new Size(70, 25), DialogResult = DialogResult.Cancel, UseVisualStyleBackColor = true }; + var buttons = new FlowLayoutPanel { FlowDirection = FlowDirection.RightToLeft, Width = 206, Height = 32, Margin = new Padding(0, 10, 0, 0) }; + buttons.Controls.AddRange([cancel, ok]); + main.Controls.Add(buttons); + AcceptButton = ok; + CancelButton = cancel; + Controls.Add(main); + ResumeLayout(false); + PerformLayout(); + } + + private static Label Caption(string text) => + new() { Text = text, AutoSize = true, Margin = new Padding(3, 6, 3, 1) }; + + private void Accept() + { + if (_askName && UserName.Length == 0) + return; + if (_password.Text.Length == 0) + { + MessageBox.Show(this, Strings.ErrorPasswordEmpty, Text, MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + if (_repeat.Parent is not null && _password.Text != _repeat.Text) + { + MessageBox.Show(this, Texts.PasswordMismatch, Text, MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + DialogResult = DialogResult.OK; + } +} diff --git a/tests/Sunfish.Tests/AdminTests.cs b/tests/Sunfish.Tests/AdminTests.cs new file mode 100644 index 0000000..aa9d4b5 --- /dev/null +++ b/tests/Sunfish.Tests/AdminTests.cs @@ -0,0 +1,276 @@ +using System.Net; +using System.Text; +using System.Text.RegularExpressions; +using Sunfish.Auth; +using Sunfish.Config; +using Sunfish.Net; +using Sunfish.Qr; +using Xunit; + +namespace Sunfish.Tests; + +public class AddressRulesTests +{ + [Theory] + [InlineData("127.0.0.1", "127.0.0.1", true)] + [InlineData("127.0.0.1", "127.0.0.2", false)] + [InlineData("192.168.1.0/24", "192.168.1.77", true)] + [InlineData("192.168.1.0/24", "192.168.2.1", false)] + [InlineData("10.0.0.0/8", "10.200.3.4", true)] + [InlineData("172.16.0.0/12", "172.31.255.1", true)] + [InlineData("172.16.0.0/12", "172.32.0.1", false)] + [InlineData("::1", "::1", true)] + [InlineData("fd00::/8", "fd12:3456::1", true)] + [InlineData("fd00::/8", "fe80::1", false)] + [InlineData("127.0.0.1", "::ffff:127.0.0.1", true)] // IPv4 vista como IPv6 + [InlineData("*", "203.0.113.9", true)] + public void MatchesAddressesAndNetworks(string rule, string address, bool expected) => + Assert.Equal(expected, AddressRules.Matches([rule], IPAddress.Parse(address))); + + [Theory] + [InlineData("192.168.1.0/24", true)] + [InlineData("::1", true)] + [InlineData("*", true)] + [InlineData("192.168.1.0/33", false)] + [InlineData("localhost", false)] + [InlineData("", false)] + public void ValidatesRules(string rule, bool valid) => Assert.Equal(valid, AddressRules.IsValid(rule)); +} + +public class LegacyImportTests +{ + [Fact] + public void ImportsTheV2Configuration() + { + using var dir = new TempDir(); + string json = $$""" + { + "Port": 9090, "Active": true, "SunfishRoot": false, "AdminPanel": true, "AdminPwd": "1234", + "Services": [ + { "Type": "WebService", "Name": "Fotos", "Enabled": true, "Location": "fotos", + "Settings": { "path": "{{dir.Path.Replace("\\", "\\\\")}}", "defaultFile": "index.html", "folderShare": true, "readOnly": false, "allowDeletion": true, "allowServerExecution": true } }, + { "Type": "WebService", "Name": "Solo lectura", "Enabled": false, "Location": "/docs", + "Settings": [ { "Key": "path", "Value": "{{dir.Path.Replace("\\", "\\\\")}}" }, { "Key": "readOnly", "Value": true }, { "Key": "allowDeletion", "Value": true } ] }, + { "Type": "Sunfish.Plugins.HelloWorld", "Name": "Hola" } + ] + } + """; + var notes = new List(); + var config = LegacyImport.FromV2(json, notes); + Assert.NotNull(config); + Assert.Equal(9090, config.Server.HttpPort); + Assert.False(config.Server.RootMenu); + Assert.False(config.Admin.Enabled); + Assert.Equal(2, config.Shares.Count); + + var fotos = config.Shares[0]; + Assert.Equal("Fotos", fotos.Name); + Assert.Equal("/fotos", fotos.Location); + Assert.Equal(dir.Path, fotos.Path); + Assert.Equal("index.html", fotos.Index); + Assert.True(fotos.Listing && fotos.Upload && fotos.Overwrite && fotos.Rename && fotos.Delete); + + var ro = config.Shares[1]; + Assert.Equal("Sololectura", ro.Name); // sin espacios: no valen en el INI + Assert.True(ShareSettings.IsValidName(ro.Name)); + Assert.False(ro.Enabled); + Assert.False(ro.Upload || ro.Overwrite || ro.Rename || ro.Delete); // solo lectura: tampoco borra + + Assert.Equal(3, notes.Count); // panel, ejecucion y el servicio que no es web + Assert.Null(SunfishConfig.Parse(config.ToText()).Errors.FirstOrDefault()); + } + + [Fact] + public void LowPortsAreReplaced() + { + var notes = new List(); + var config = LegacyImport.FromV2("{\"Port\": 90}", notes); + Assert.Equal(8080, config!.Server.HttpPort); + Assert.Single(notes); + } + + [Fact] + public void RejectsNonJson() => Assert.Null(LegacyImport.FromV2("[Server]\nName = x", [])); +} + +public class QrImageTests +{ + [Fact] + public void PngHasTheExpectedSize() + { + var qr = QrCode.Encode("http://10.0.0.1:8080/"); + var png = QrImage.Png(qr, 3); + Assert.Equal(new byte[] { 0x89, (byte)'P', (byte)'N', (byte)'G' }, png[..4]); + int width = (png[16] << 24) | (png[17] << 16) | (png[18] << 8) | png[19]; + Assert.Equal((qr.Size + 8) * 3, width); + } + + [Fact] + public void HalfBlocksCoverTheWholeCode() + { + var qr = QrCode.Encode("http://10.0.0.1:8080/"); + var lines = QrImage.HalfBlocks(qr, 2).ToList(); + Assert.Equal((qr.Size + 4 + 1) / 2, lines.Count); + Assert.All(lines, l => Assert.Equal(qr.Size + 4, l.Length)); + } +} + +/// Panel de administracion contra el servidor real. +public class AdminServerTests : IAsyncLifetime +{ + private readonly TempDir _dir = new(); + private readonly TempDir _other = new(); + private TestServer _server = null!; + private static readonly string Auth = "Authorization: Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("admin:secreto")) + "\r\n"; + + public Task InitializeAsync() + { + var config = new SunfishConfig(); + config.Admin.Enabled = true; + config.Admin.PasswordHash = Passwords.Hash("secreto", 1000); + config.Users.Add(new UserConfig { Name = "ana", PasswordHash = Passwords.Hash("1234", 1000) }); + config.Shares.Add(new ShareConfig { Name = "pub", Location = "/pub", Path = _dir.Path, Users = ["ana"] }); + _server = new TestServer(config); + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + await _server.DisposeAsync(); + _dir.Dispose(); + _other.Dispose(); + } + + private SunfishConfig Config => _server.Host.Config; + + private async Task TokenAsync(string action) + { + var page = await _server.GetAsync(action == "share-save" ? "/$sunfish/admin/share" : "/$sunfish/admin/", extraHeaders: Auth); + var m = Regex.Match(page.BodyText, $"NAME=\"a\" VALUE=\"{action}\"> PostAsync(string action, string fields, string? token = null) + { + token ??= await TokenAsync(action); + string body = $"a={action}&t={Uri.EscapeDataString(token)}&{fields}"; + return await _server.SendAsync("POST /$sunfish/admin/ HTTP/1.0\r\n" + Auth + "Content-Type: application/x-www-form-urlencoded\r\n" + + $"Content-Length: {Encoding.Latin1.GetByteCount(body)}\r\n\r\n{body}"); + } + + [Fact] + public async Task RequiresThePassword() + { + var none = await _server.GetAsync("/$sunfish/admin/"); + Assert.Equal(401, none.Status); + Assert.Contains("admin", none.Headers["WWW-Authenticate"]); + var wrong = await _server.GetAsync("/$sunfish/admin/", extraHeaders: "Authorization: Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("admin:no")) + "\r\n"); + Assert.Equal(401, wrong.Status); + var ok = await _server.GetAsync("/$sunfish/admin/", extraHeaders: Auth); + Assert.Equal(200, ok.Status); + Assert.Contains("/pub", ok.BodyText); + Assert.Contains("qr?u=http", ok.BodyText.Replace("%3A", ":"), StringComparison.Ordinal); + Assert.DoesNotContain(" s.Name == "nuevo"); + + var r = await PostAsync("share-save", $"orig=&name=nuevo&location=%2Fnuevo&path={path}&index=&enabled=1&listing=1&upload=1&u.ana=1"); + Assert.Equal(302, r.Status); + var share = Assert.Single(Config.Shares, s => s.Name == "nuevo"); + Assert.True(share.Upload); + Assert.False(share.Delete); + Assert.Equal(["ana"], share.Users); + Assert.Equal(200, (await _server.GetAsync("/$sunfish/admin/share?n=nuevo", extraHeaders: Auth)).Status); + + r = await PostAsync("share-save", $"orig=nuevo&name=renombrado&location=%2Fnuevo&path={path}&enabled=1"); + Assert.Equal(302, r.Status); + Assert.Contains(Config.Shares, s => s.Name == "renombrado" && !s.Upload && s.Users.Count == 0); + Assert.DoesNotContain(Config.Shares, s => s.Name == "nuevo"); + + r = await PostAsync("share-del", "n=renombrado"); + Assert.Equal(302, r.Status); + Assert.DoesNotContain(Config.Shares, s => s.Name == "renombrado"); + } + + [Fact] + public async Task ManagesUsers() + { + Assert.Equal(200, (await PostAsync("user-add", "name=luis&p1=a&p2=b")).Status); // no coinciden + Assert.DoesNotContain(Config.Users, u => u.Name == "luis"); + Assert.Equal(302, (await PostAsync("user-add", "name=luis&p1=clave&p2=clave")).Status); + Assert.True(Passwords.Verify("clave", Config.Users.Single(u => u.Name == "luis").PasswordHash)); + Assert.Equal(302, (await PostAsync("user-pass", "name=luis&p1=otra&p2=otra")).Status); + Assert.True(Passwords.Verify("otra", Config.Users.Single(u => u.Name == "luis").PasswordHash)); + Assert.Equal(200, (await PostAsync("user-add", "name=admin&p1=x&p2=x")).Status); // reservado + + Assert.Equal(302, (await PostAsync("user-del", "name=ana")).Status); + Assert.DoesNotContain(Config.Users, u => u.Name == "ana"); + Assert.Empty(Config.Shares.Single(s => s.Name == "pub").Users); + } + + [Fact] + public async Task PanelSettingsNeedTheCurrentPasswordAndCannotLockOut() + { + Assert.Equal(200, (await PostAsync("admin", "allow=127.0.0.1&cur=mala")).Status); + Assert.Equal(200, (await PostAsync("admin", "allow=10.0.0.0%2F8&cur=secreto")).Status); + Assert.Contains("127.0.0.1", Config.Admin.AllowFrom); + Assert.Equal(200, (await PostAsync("admin", "allow=nada&cur=secreto")).Status); + + Assert.Equal(302, (await PostAsync("admin", "allow=127.0.0.1%2C+192.168.0.0%2F16&cur=secreto")).Status); + Assert.Equal(["127.0.0.1", "192.168.0.0/16"], Config.Admin.AllowFrom); + Assert.Equal(302, (await PostAsync("admin", "allow=127.0.0.1&cur=secreto&p1=nueva&p2=nueva")).Status); + Assert.True(Passwords.Verify("nueva", Config.Admin.PasswordHash)); + } + + [Fact] + public async Task ServesQrAndLog() + { + var qr = await _server.GetAsync("/$sunfish/admin/qr?u=" + Uri.EscapeDataString("http://10.0.0.1:8080/"), extraHeaders: Auth); + Assert.Equal("image/png", qr.Headers["Content-Type"]); + Assert.Equal(404, (await _server.GetAsync("/$sunfish/admin/qr?u=javascript:alert(1)", extraHeaders: Auth)).Status); + var log = await _server.GetAsync("/$sunfish/admin/log", extraHeaders: Auth); + Assert.Contains("
", log.BodyText);
+    }
+}
diff --git a/tests/Sunfish.Tests/ConfigTests.cs b/tests/Sunfish.Tests/ConfigTests.cs
new file mode 100644
index 0000000..9a04dc7
--- /dev/null
+++ b/tests/Sunfish.Tests/ConfigTests.cs
@@ -0,0 +1,147 @@
+using System.Net;
+using Sunfish.Config;
+
+namespace Sunfish.Tests;
+
+public class ConfigTests
+{
+    private const string Sample = """
+        # Comentario de cabecera
+
+        [Server]
+        Name = Pruebas
+        HttpPort = 9090
+        ; otro comentario
+        Bind = 127.0.0.1
+
+        [Share web]
+        Location = /
+        Path = C:\www
+        Listing = false
+
+        [Share fotos]
+        Location = fotos/
+        Path = D:\Fotos
+        Upload = yes
+        Users = ana, pepe
+        """;
+
+    [Fact]
+    public void ParsesValuesAndDefaults()
+    {
+        var config = SunfishConfig.Parse(Sample);
+        Assert.Empty(config.Errors);
+        Assert.Equal("Pruebas", config.Server.Name);
+        Assert.Equal(9090, config.Server.HttpPort);
+        Assert.Equal([IPAddress.Loopback], config.Server.Bind);
+        Assert.Equal(2121, config.Server.FtpPort);
+
+        var web = config.Shares[0];
+        Assert.Equal("web", web.Name);
+        Assert.False(web.Listing);
+        Assert.Equal("index.html", web.Index);
+        Assert.True(web.Mac);
+        Assert.True(web.WebDav);
+
+        var fotos = config.Shares[1];
+        Assert.Equal("/fotos", fotos.Location);
+        Assert.True(fotos.Upload);
+        Assert.False(fotos.Delete);
+        Assert.Equal(["ana", "pepe"], fotos.Users);
+    }
+
+    [Fact]
+    public void RewritingKeepsCommentsAndOrder()
+    {
+        var config = SunfishConfig.Parse(Sample);
+        config.Server.HttpPort = 8181;
+        config.Shares[1].Delete = true;
+        string text = config.ToText().Replace("\r\n", "\n");
+
+        Assert.Contains("# Comentario de cabecera", text);
+        Assert.Contains("; otro comentario", text);
+        Assert.Contains("HttpPort = 8181", text);
+        Assert.Contains("Delete = true", text);
+        Assert.True(text.IndexOf("[Share web]", StringComparison.Ordinal) < text.IndexOf("[Share fotos]", StringComparison.Ordinal));
+        // Los valores por defecto que no estaban no se anaden.
+        Assert.DoesNotContain("Mac =", text);
+    }
+
+    [Fact]
+    public void RoundTripWithoutChangesIsIdentical()
+    {
+        var config = SunfishConfig.Parse(Sample);
+        string normalizedSample = Sample.Replace("\r\n", "\n") + "\n";
+        // Location se normaliza al leer ("fotos/" -> "/fotos"): es el unico cambio esperado.
+        string expected = normalizedSample.Replace("Location = fotos/", "Location = /fotos");
+        Assert.Equal(expected, config.ToText().Replace("\r\n", "\n"));
+    }
+
+    [Fact]
+    public void AddingAndRemovingShares()
+    {
+        var config = SunfishConfig.Parse(Sample);
+        config.Shares.RemoveAt(0);
+        config.Shares.Add(new ShareConfig { Name = "musica", Location = "/musica", Path = "E:\\Musica", Zip = true });
+        string text = config.ToText().Replace("\r\n", "\n");
+
+        Assert.DoesNotContain("[Share web]", text);
+        Assert.Contains("[Share musica]\nLocation = /musica\nPath = E:\\Musica\nZip = true\n", text);
+
+        var reread = SunfishConfig.Parse(text);
+        Assert.Equal(["fotos", "musica"], reread.Shares.Select(s => s.Name));
+    }
+
+    [Fact]
+    public void ReportsErrorsWithLineNumbers()
+    {
+        var config = SunfishConfig.Parse("""
+            [Server]
+            HttpPort = muchos
+            esto no es nada
+            [Share a]
+            Location = /x
+            [Share b]
+            Location = /x
+            Path = C:\b
+            [Raro]
+            """);
+        Assert.Contains(config.Errors, e => e.Contains("2") && e.Contains("muchos"));
+        Assert.Contains(config.Errors, e => e.Contains("3"));
+        Assert.Contains(config.Errors, e => e.Contains("Path"));
+        Assert.Contains(config.Errors, e => e.Contains("Raro"));
+        Assert.Equal(8080, config.Server.HttpPort);
+        Assert.False(config.Shares[0].Enabled); // sin Path
+        Assert.True(config.Shares[1].Enabled);  // la location ya no esta ocupada por un share activo
+    }
+
+    [Fact]
+    public void RejectsReservedAndInvalidLocations()
+    {
+        var config = SunfishConfig.Parse("""
+            [Share a]
+            Location = /$sunfish/x
+            Path = C:\a
+            [Share b]
+            Location = /a/../b
+            Path = C:\b
+            """);
+        Assert.All(config.Shares, s => Assert.False(s.Enabled));
+        Assert.Equal(2, config.Errors.Count);
+    }
+
+    [Fact]
+    public void SaveIsAtomicAndReadable()
+    {
+        using var dir = new TempDir();
+        string path = Path.Combine(dir.Path, "sub", "sunfish.conf");
+        var config = new SunfishConfig();
+        config.Shares.Add(new ShareConfig { Name = "x", Location = "/x", Path = dir.Path });
+        config.Save(path);
+
+        Assert.True(File.Exists(path));
+        Assert.False(File.Exists(path + ".tmp"));
+        var loaded = SunfishConfig.Load(path);
+        Assert.Equal("/x", loaded.Shares.Single().Location);
+    }
+}
diff --git a/tests/Sunfish.Tests/DavTests.cs b/tests/Sunfish.Tests/DavTests.cs
new file mode 100644
index 0000000..5aff880
--- /dev/null
+++ b/tests/Sunfish.Tests/DavTests.cs
@@ -0,0 +1,258 @@
+using System.Net;
+using System.Text;
+using Sunfish.Auth;
+using Sunfish.Config;
+using Sunfish.Dav;
+using Sunfish.Host;
+using Sunfish.Net;
+
+namespace Sunfish.Tests;
+
+/// WebDAV contra un servidor real: en bruto (como el Explorador o Finder) y con DavClient.
+public class DavServerTests : IAsyncLifetime
+{
+    private readonly TempDir _rw = new();
+    private readonly TempDir _ro = new();
+    private readonly TempDir _off = new();
+    private TestServer _server = null!;
+
+    public Task InitializeAsync()
+    {
+        _rw.File("existe.txt", "original");
+        _rw.File("año/dentro.txt", "x");
+        _ro.File("solo.txt", "lectura");
+        _off.File("nada.txt", "n");
+        var config = new SunfishConfig();
+        config.Users.Add(new UserConfig { Name = "ana", PasswordHash = Passwords.Hash("1234", 1000) });
+        config.Shares.Add(new ShareConfig { Name = "rw", Location = "/rw", Path = _rw.Path, Upload = true, Rename = true, Delete = true });
+        config.Shares.Add(new ShareConfig { Name = "ro", Location = "/ro", Path = _ro.Path, Users = ["ana"] });
+        config.Shares.Add(new ShareConfig { Name = "off", Location = "/off", Path = _off.Path, WebDav = false });
+        _server = new TestServer(config);
+        return Task.CompletedTask;
+    }
+
+    public async Task DisposeAsync()
+    {
+        await _server.DisposeAsync();
+        _rw.Dispose();
+        _ro.Dispose();
+        _off.Dispose();
+    }
+
+    private Task Dav(string method, string path, string headers = "", string body = "") =>
+        _server.SendAsync($"{method} {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n{headers}" +
+                          $"Content-Length: {Encoding.UTF8.GetByteCount(body)}\r\n\r\n{body}");
+
+    private DavClient Client(string? user = null, string? password = null) =>
+        new(new Uri($"http://127.0.0.1:{_server.Port}/"), user, password);
+
+    [Fact]
+    public async Task OptionsAnnouncesDav()
+    {
+        var r = await Dav("OPTIONS", "/");
+        Assert.Equal(200, r.Status);
+        Assert.Equal("1, 2", r.Headers["DAV"]);
+        Assert.Equal("DAV", r.Headers["MS-Author-Via"]);
+        Assert.Contains("PROPFIND", r.Headers["Allow"]);
+    }
+
+    [Fact]
+    public async Task PropFindListsTheVirtualRootAndShares()
+    {
+        var r = await Dav("PROPFIND", "/", "Depth: 1\r\n");
+        Assert.Equal(207, r.Status);
+        string xml = Encoding.UTF8.GetString(r.Body);
+        Assert.Contains("/rw/", xml);
+        Assert.Contains("/off/", xml);
+
+        r = await Dav("PROPFIND", "/rw/", "Depth: 1\r\n");
+        xml = Encoding.UTF8.GetString(r.Body);
+        Assert.Contains("/rw/a%C3%B1o/", xml);
+        Assert.Contains("8", xml);
+        Assert.Contains("", xml);
+
+        Assert.Equal(403, (await Dav("PROPFIND", "/rw/", "Depth: infinity\r\n")).Status);
+        Assert.Equal(405, (await Dav("PROPFIND", "/off/", "Depth: 1\r\n")).Status);
+    }
+
+    [Fact]
+    public async Task PropFindWithSpecificProps()
+    {
+        string body = "";
+        var r = await Dav("PROPFIND", "/rw/existe.txt", "Depth: 0\r\nContent-Type: application/xml\r\n", body);
+        string xml = Encoding.UTF8.GetString(r.Body);
+        Assert.Contains("8", xml);
+        Assert.Contains("404 Not Found", xml);
+        Assert.DoesNotContain("displayname", xml);
+    }
+
+    [Fact]
+    public async Task ExternalEntitiesAreRejected()
+    {
+        string body = "]>&e;";
+        var r = await Dav("PROPFIND", "/rw/existe.txt", "Depth: 0\r\n", body);
+        Assert.DoesNotContain("fonts", Encoding.UTF8.GetString(r.Body), StringComparison.OrdinalIgnoreCase);
+    }
+
+    [Fact]
+    public async Task WindowsStyleSaveSequence()
+    {
+        // El Explorador: PUT vacio, LOCK, PUT con datos, PROPPATCH, UNLOCK.
+        Assert.Equal(201, (await Dav("PUT", "/rw/nuevo.txt")).Status);
+        var lockResponse = await Dav("LOCK", "/rw/nuevo.txt", "Timeout: Second-3600\r\n",
+            "");
+        Assert.Equal(200, lockResponse.Status);
+        Assert.StartsWith("00000020")).Status);
+        Assert.Equal(204, (await Dav("UNLOCK", "/rw/nuevo.txt", $"Lock-Token: {lockResponse.Headers["Lock-Token"]}\r\n")).Status);
+        Assert.Equal("contenido", File.ReadAllText(Path.Combine(_rw.Path, "nuevo.txt")));
+
+        Assert.Equal(409, (await Dav("PUT", "/rw/existe.txt", body: "pisado")).Status); // no vacio y sin Overwrite
+    }
+
+    [Fact]
+    public async Task FinderMetadataIsAccepted()
+    {
+        Assert.Equal(201, (await Dav("PUT", "/rw/._existe.txt", body: "appledouble")).Status);
+        Assert.True(File.Exists(Path.Combine(_rw.Path, "._existe.txt")));
+        var list = Encoding.UTF8.GetString((await Dav("PROPFIND", "/rw/", "Depth: 1\r\n")).Body);
+        Assert.DoesNotContain("._existe", list); // oculto en los listados
+        Assert.Equal(400, (await Dav("PUT", "/rw/.oculto", body: "x")).Status);
+    }
+
+    [Fact]
+    public async Task MkColCopyMoveDelete()
+    {
+        Assert.Equal(201, (await Dav("MKCOL", "/rw/carpeta")).Status);
+        Assert.Equal(405, (await Dav("MKCOL", "/rw/carpeta")).Status);
+        Assert.Equal(409, (await Dav("MKCOL", "/rw/no/existe")).Status);
+
+        Assert.Equal(201, (await Dav("COPY", "/rw/existe.txt", $"Destination: http://localhost:{_server.Port}/rw/carpeta/copia.txt\r\n")).Status);
+        Assert.Equal(201, (await Dav("MOVE", "/rw/carpeta/copia.txt", "Destination: /rw/movida.txt\r\n")).Status);
+        Assert.Equal(412, (await Dav("MOVE", "/rw/movida.txt", "Destination: /rw/existe.txt\r\nOverwrite: F\r\n")).Status);
+        Assert.Equal(204, (await Dav("MOVE", "/rw/movida.txt", "Destination: /rw/existe.txt\r\nOverwrite: T\r\n")).Status);
+        Assert.Equal(502, (await Dav("COPY", "/rw/existe.txt", "Destination: /ro/x.txt\r\n")).Status);
+        Assert.Equal(400, (await Dav("COPY", "/rw/existe.txt", "Destination: /rw/../../x.txt\r\n")).Status);
+
+        Assert.Equal(201, (await Dav("COPY", "/rw/a%C3%B1o/", "Destination: /rw/copia-dir/\r\n")).Status);
+        Assert.True(File.Exists(Path.Combine(_rw.Path, "copia-dir", "dentro.txt")));
+        Assert.Equal(204, (await Dav("DELETE", "/rw/copia-dir/")).Status);
+        Assert.False(Directory.Exists(Path.Combine(_rw.Path, "copia-dir")));
+        Assert.Equal(403, (await Dav("DELETE", "/rw/")).Status); // la raiz del share no se borra
+    }
+
+    [Fact]
+    public async Task ProtectedSharesNeedCredentials()
+    {
+        Assert.Equal(401, (await Dav("PROPFIND", "/ro/", "Depth: 1\r\n")).Status);
+        using var client = Client("ana", "1234");
+        var items = await client.ListAsync("/ro");
+        Assert.Equal(["solo.txt"], items.Select(i => i.Name));
+        var e = await Assert.ThrowsAsync(() => client.CreateFolderAsync("/ro/nueva"));
+        Assert.Equal(403, e.Status);
+    }
+
+    [Fact]
+    public async Task ClientRoundTrip()
+    {
+        using var client = Client();
+        var root = await client.ListAsync("/");
+        Assert.Contains(root, i => i.Name == "rw" && i.IsCollection);
+
+        var items = await client.ListAsync("/rw");
+        Assert.Contains(items, i => i.Name == "año" && i.IsCollection);
+        Assert.Contains(items, i => i.Name == "existe.txt" && i.Size == 8 && i.Modified is not null);
+
+        await client.UploadAsync("/rw/subido.txt", new MemoryStream(Encoding.UTF8.GetBytes("hola")));
+        var ms = new MemoryStream();
+        await client.DownloadAsync("/rw/subido.txt", ms);
+        Assert.Equal("hola", Encoding.UTF8.GetString(ms.ToArray()));
+
+        using var local = new TempDir();
+        local.File("árbol/hoja.txt", "h");
+        await client.UploadTreeAsync(Path.Combine(local.Path, "árbol"), "/rw");
+        Assert.True(File.Exists(Path.Combine(_rw.Path, "árbol", "hoja.txt")));
+
+        using var back = new TempDir();
+        await client.DownloadTreeAsync(new DavItem("árbol", "/rw/árbol", true, 0, null), back.Path);
+        Assert.Equal("h", File.ReadAllText(Path.Combine(back.Path, "árbol", "hoja.txt")));
+
+        await client.MoveAsync("/rw/subido.txt", "/rw/renombrado.txt", false);
+        await client.DeleteAsync("/rw/renombrado.txt", false);
+        Assert.False(File.Exists(Path.Combine(_rw.Path, "renombrado.txt")));
+    }
+
+    [Theory]
+    [InlineData("192.168.1.10:8080", "http://192.168.1.10:8080/", "/")]
+    [InlineData("http://equipo:8080/fotos/2026/", "http://equipo:8080/", "/fotos/2026/")]
+    [InlineData("equipo/a%20b", "http://equipo/", "/a b")]
+    public void ParsesAddresses(string text, string server, string path)
+    {
+        Assert.True(DavClient.TryParseAddress(text, out var uri, out var p));
+        Assert.Equal(server, uri.ToString());
+        Assert.Equal(path, p);
+    }
+}
+
+public class DnsMessageTests
+{
+    [Fact]
+    public void RoundTripsDnsSdRecords()
+    {
+        var m = new DnsMessage { Id = 7, IsResponse = true };
+        m.Questions.Add(("_webdav._tcp.local", DnsType.Ptr, true));
+        m.Answers.Add(new DnsRecord("_webdav._tcp.local", DnsType.Ptr, 4500) { Target = "Sunfish (PC.1)._webdav._tcp.local" });
+        m.Additional.Add(new DnsRecord("Sunfish (PC.1)._webdav._tcp.local", DnsType.Srv, 120) { Target = "sunfish-pc.local", Port = 8080, CacheFlush = true });
+        m.Additional.Add(new DnsRecord("Sunfish (PC.1)._webdav._tcp.local", DnsType.Txt, 4500) { Text = ["sunfish=3", "path=/"] });
+        m.Additional.Add(new DnsRecord("sunfish-pc.local", DnsType.A, 120) { Address = IPAddress.Parse("192.168.1.10") });
+
+        var d = DnsMessage.Decode(m.Encode())!;
+        Assert.True(d.IsResponse);
+        Assert.Equal(("_webdav._tcp.local", DnsType.Ptr, true), d.Questions[0]);
+        Assert.Equal("Sunfish (PC.1)._webdav._tcp.local", d.Answers[0].Target);
+        var srv = d.Additional.Single(r => r.Type == DnsType.Srv);
+        Assert.Equal(8080, srv.Port);
+        Assert.Equal("sunfish-pc.local", srv.Target);
+        Assert.True(srv.CacheFlush);
+        Assert.Equal(["sunfish=3", "path=/"], d.Additional.Single(r => r.Type == DnsType.Txt).Text);
+        Assert.Equal(IPAddress.Parse("192.168.1.10"), d.Additional.Single(r => r.Type == DnsType.A).Address);
+    }
+
+    [Fact]
+    public void ReadsCompressedNames()
+    {
+        // Pregunta "a.local" y una respuesta PTR cuyo nombre apunta a la pregunta (0xC00C).
+        byte[] packet =
+        [
+            0, 1, 0x84, 0, 0, 1, 0, 1, 0, 0, 0, 0,
+            1, (byte)'a', 5, (byte)'l', (byte)'o', (byte)'c', (byte)'a', (byte)'l', 0, 0, 12, 0, 1,
+            0xC0, 12, 0, 12, 0, 1, 0, 0, 0, 60, 0, 4, 1, (byte)'b', 0xC0, 14,
+        ];
+        var d = DnsMessage.Decode(packet)!;
+        Assert.Equal("a.local", d.Answers[0].Name);
+        Assert.Equal("b.local", d.Answers[0].Target);
+    }
+
+    [Fact]
+    public void GarbageIsIgnored() => Assert.Null(DnsMessage.Decode(new byte[] { 1, 2, 3 }));
+}
+
+/// Anuncio y busqueda reales por mDNS en este equipo (se salta si el puerto 5353 no esta disponible).
+[Collection(EnvironmentCollection.Name)]
+public class DiscoveryTests
+{
+    [Fact]
+    public async Task FindsItselfOnTheLan()
+    {
+        if (Discovery.LocalIPv4().Count == 0)
+            return; // sin red
+        var log = new Sunfish.Log.ServerLog();
+        await using var announcer = MdnsAnnouncer.Start(new AnnouncedServices("PruebaDescubrimiento", 18080, true, 0), log);
+        if (announcer is null)
+            return; // el sistema no comparte el puerto 5353
+        var found = await Discovery.BrowseAsync(TimeSpan.FromSeconds(2));
+        Assert.Contains(found, s => s.Name.StartsWith("PruebaDescubrimiento", StringComparison.Ordinal) && s.Port == 18080 && s.IsSunfish);
+    }
+}
diff --git a/tests/Sunfish.Tests/FtpTests.cs b/tests/Sunfish.Tests/FtpTests.cs
new file mode 100644
index 0000000..eef33e8
--- /dev/null
+++ b/tests/Sunfish.Tests/FtpTests.cs
@@ -0,0 +1,385 @@
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using System.Text.RegularExpressions;
+using Sunfish.Auth;
+using Sunfish.Config;
+using Sunfish.Ftp;
+using Sunfish.Shares;
+
+namespace Sunfish.Tests;
+
+/// Cliente FTP minimo para las pruebas: control en texto y datos en modo pasivo o activo.
+internal sealed class FtpTestClient : IAsyncDisposable
+{
+    private readonly TcpClient _tcp = new();
+    private Stream _stream = Stream.Null;
+    private readonly List _pending = new();
+
+    public Encoding Encoding { get; set; } = Encoding.Latin1;
+
+    public static async Task ConnectAsync(int port)
+    {
+        var client = new FtpTestClient();
+        await client._tcp.ConnectAsync(IPAddress.Loopback, port);
+        client._stream = client._tcp.GetStream();
+        var banner = await client.ReadReplyAsync();
+        Assert.Equal(220, banner.Code);
+        return client;
+    }
+
+    public async Task<(int Code, string Text)> CommandAsync(string line)
+    {
+        await SendAsync(line);
+        return await ReadReplyAsync();
+    }
+
+    public Task SendAsync(string line) => _stream.WriteAsync(Encoding.GetBytes(line + "\r\n")).AsTask();
+
+    /// Respuesta completa (tambien las de varias lineas "211-...211 End").
+    public async Task<(int Code, string Text)> ReadReplyAsync()
+    {
+        string first = await ReadLineAsync();
+        int code = int.Parse(first[..3]);
+        var text = new StringBuilder(first);
+        if (first.Length > 3 && first[3] == '-')
+        {
+            while (true)
+            {
+                string line = await ReadLineAsync();
+                text.Append('\n').Append(line);
+                if (line.StartsWith($"{code} ", StringComparison.Ordinal))
+                    break;
+            }
+        }
+        return (code, text.ToString());
+    }
+
+    private async Task ReadLineAsync()
+    {
+        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+        var one = new byte[1];
+        while (true)
+        {
+            int n = await _stream.ReadAsync(one, cts.Token);
+            if (n == 0)
+                throw new IOException("closed");
+            if (one[0] == '\n')
+                break;
+            _pending.Add(one[0]);
+        }
+        string line = Encoding.GetString(_pending.ToArray()).TrimEnd('\r');
+        _pending.Clear();
+        return line;
+    }
+
+    public async Task LoginAsync(string user = "anonymous", string password = "test@")
+    {
+        Assert.Equal(331, (await CommandAsync("USER " + user)).Code);
+        var pass = await CommandAsync("PASS " + password);
+        Assert.Equal(230, pass.Code);
+    }
+
+    /// Comando con conexion de datos pasiva; devuelve la respuesta final y lo recibido.
+    public async Task<(int Code, byte[] Data)> PassiveAsync(string command, byte[]? upload = null)
+    {
+        var pasv = await CommandAsync("EPSV");
+        Assert.Equal(229, pasv.Code);
+        int port = int.Parse(Regex.Match(pasv.Text, @"\|\|\|(\d+)\|").Groups[1].Value);
+        using var data = new TcpClient();
+        await data.ConnectAsync(IPAddress.Loopback, port);
+        await SendAsync(command);
+        var first = await ReadReplyAsync();
+        if (first.Code >= 400)
+            return (first.Code, []);
+        Assert.Equal(150, first.Code);
+        var ms = new MemoryStream();
+        var stream = data.GetStream();
+        if (upload is not null)
+        {
+            await stream.WriteAsync(upload);
+            data.Client.Shutdown(SocketShutdown.Send);
+        }
+        await stream.CopyToAsync(ms);
+        data.Close();
+        var final = await ReadReplyAsync();
+        return (final.Code, ms.ToArray());
+    }
+
+    public async Task ListAsync(string command = "LIST")
+    {
+        var (code, data) = await PassiveAsync(command);
+        Assert.Equal(226, code);
+        return Encoding.GetString(data);
+    }
+
+    public async ValueTask DisposeAsync()
+    {
+        try { await SendAsync("QUIT"); } catch (IOException) { }
+        _tcp.Dispose();
+    }
+}
+
+public class FtpUnitTests
+{
+    [Theory]
+    [InlineData("", "/a/b")]
+    [InlineData("c", "/a/b/c")]
+    [InlineData("../c", "/a/c")]
+    [InlineData("/x/./y", "/x/y")]
+    [InlineData("../../..", "/")]
+    [InlineData("\\dos\\path", "/dos/path")]
+    public void ResolvesPaths(string argument, string expected) =>
+        Assert.Equal(expected, FtpPath.Format(FtpPath.Resolve(["a", "b"], argument)!));
+
+    [Fact]
+    public void RejectsInvalidSegments() => Assert.Null(FtpPath.Resolve([], "a:b"));
+
+    [Fact]
+    public void FormatsUnixListings()
+    {
+        var now = new DateTime(2026, 11, 4, 12, 0, 0);
+        Assert.Equal("-rw-r--r-- 1 ftp ftp         1234 Nov  3 10:05 nota.txt",
+            FtpListing.UnixLine(new FileEntry("nota.txt", false, 1234, new DateTime(2026, 11, 3, 10, 5, 0)), now));
+        Assert.Equal("drwxr-xr-x 1 ftp ftp            0 Jan  2  2020 viejo",
+            FtpListing.UnixLine(new FileEntry("viejo", true, 0, new DateTime(2020, 1, 2)), now));
+    }
+
+    [Fact]
+    public void ParsesPortCommands()
+    {
+        Assert.Equal(new IPEndPoint(IPAddress.Parse("10.1.2.3"), 4 * 256 + 5), DataChannel.ParsePort("10,1,2,3,4,5"));
+        Assert.Null(DataChannel.ParsePort("10,1,2,3,4"));
+        Assert.Equal(new IPEndPoint(IPAddress.IPv6Loopback, 6000), DataChannel.ParseEprt("|2|::1|6000|"));
+        Assert.Equal((50000, 50010), DataChannel.ParseRange("50000-50010"));
+        Assert.Equal((0, 0), DataChannel.ParseRange("80-90"));
+    }
+
+    [Fact]
+    public void ActiveModeOnlyToTheClientAndHighPorts()
+    {
+        using var channel = new DataChannel(IPAddress.Parse("10.0.0.5"));
+        Assert.False(channel.SetActive(new IPEndPoint(IPAddress.Parse("10.0.0.6"), 5000)));
+        Assert.False(channel.SetActive(new IPEndPoint(IPAddress.Parse("10.0.0.5"), 25)));
+        Assert.True(channel.SetActive(new IPEndPoint(IPAddress.Parse("10.0.0.5"), 5000)));
+    }
+}
+
+/// Servidor FTP real sobre localhost.
+public class FtpServerTests : IAsyncLifetime
+{
+    private readonly TempDir _pub = new();
+    private readonly TempDir _rw = new();
+    private readonly TempDir _priv = new();
+    private TestServer _server = null!;
+
+    public Task InitializeAsync()
+    {
+        _pub.File("leeme.txt", "linea 1\nlinea 2\n");
+        _pub.File("año.txt", "acentos");
+        _pub.File("docs/manual.txt", "0123456789");
+        _rw.File("existe.txt", "original");
+        _priv.File("secreto.txt", "s");
+
+        var config = new SunfishConfig();
+        config.Users.Add(new UserConfig { Name = "ana", PasswordHash = Passwords.Hash("1234", 1000) });
+        config.Shares.Add(new ShareConfig { Name = "pub", Location = "/pub", Path = _pub.Path });
+        config.Shares.Add(new ShareConfig { Name = "rw", Location = "/rw", Path = _rw.Path, Upload = true, Rename = true, Delete = true });
+        config.Shares.Add(new ShareConfig { Name = "priv", Location = "/priv", Path = _priv.Path, Users = ["ana"] });
+        config.Shares.Add(new ShareConfig { Name = "noftp", Location = "/noftp", Path = _pub.Path, Ftp = false });
+        _server = new TestServer(config);
+        return Task.CompletedTask;
+    }
+
+    public async Task DisposeAsync()
+    {
+        await _server.DisposeAsync();
+        _pub.Dispose();
+        _rw.Dispose();
+        _priv.Dispose();
+    }
+
+    private async Task Anonymous()
+    {
+        var c = await FtpTestClient.ConnectAsync(_server.FtpPort);
+        await c.LoginAsync();
+        return c;
+    }
+
+    [Fact]
+    public async Task RequiresLoginBeforeCommands()
+    {
+        await using var c = await FtpTestClient.ConnectAsync(_server.FtpPort);
+        Assert.Equal(530, (await c.CommandAsync("PWD")).Code);
+        Assert.Equal(215, (await c.CommandAsync("SYST")).Code);
+        Assert.Contains("MLST", (await c.CommandAsync("FEAT")).Text);
+    }
+
+    [Fact]
+    public async Task AnonymousSeesOnlyPublicFtpShares()
+    {
+        await using var c = await Anonymous();
+        string root = await c.ListAsync("NLST");
+        Assert.Contains("pub", root);
+        Assert.Contains("rw", root);
+        Assert.DoesNotContain("priv", root);
+        Assert.DoesNotContain("noftp", root);
+        Assert.Equal(550, (await c.CommandAsync("CWD /priv")).Code);
+    }
+
+    [Fact]
+    public async Task UsersSeeTheirShares()
+    {
+        await using var c = await FtpTestClient.ConnectAsync(_server.FtpPort);
+        await c.LoginAsync("ana", "1234");
+        Assert.Contains("priv", await c.ListAsync("NLST"));
+        Assert.Equal(250, (await c.CommandAsync("CWD priv")).Code);
+        var (code, data) = await c.PassiveAsync("RETR secreto.txt");
+        Assert.Equal(226, code);
+        Assert.Equal("s", Encoding.ASCII.GetString(data));
+    }
+
+    [Fact]
+    public async Task WrongPasswordsAreRejected()
+    {
+        await using var c = await FtpTestClient.ConnectAsync(_server.FtpPort);
+        Assert.Equal(331, (await c.CommandAsync("USER ana")).Code);
+        Assert.Equal(530, (await c.CommandAsync("PASS mala")).Code);
+    }
+
+    [Fact]
+    public async Task NavigatesAndListsLikeLs()
+    {
+        await using var c = await Anonymous();
+        Assert.Equal(250, (await c.CommandAsync("CWD pub/docs")).Code);
+        Assert.Contains("\"/pub/docs\"", (await c.CommandAsync("PWD")).Text);
+        Assert.Equal(250, (await c.CommandAsync("CDUP")).Code);
+        string list = await c.ListAsync("LIST -la");
+        Assert.Matches(@"^drwxr-xr-x 1 ftp ftp\s+0 \w{3} [ \d]\d [\d :]{5} docs\r$", list.Split('\n')[0]);
+        Assert.Contains("leeme.txt", list);
+    }
+
+    [Fact]
+    public async Task NamesUseLatin1UnlessUtf8IsRequested()
+    {
+        await using var c = await Anonymous();
+        await c.CommandAsync("CWD /pub");
+        c.Encoding = Encoding.Latin1;
+        Assert.Contains("año.txt", await c.ListAsync("NLST"));
+
+        Assert.Equal(200, (await c.CommandAsync("OPTS UTF8 ON")).Code);
+        c.Encoding = Encoding.UTF8;
+        Assert.Contains("año.txt", await c.ListAsync("NLST"));
+        var (code, data) = await c.PassiveAsync("RETR año.txt");
+        Assert.Equal(226, code);
+        Assert.Equal("acentos", Encoding.ASCII.GetString(data));
+    }
+
+    [Fact]
+    public async Task MlsdUsesFacts()
+    {
+        await using var c = await Anonymous();
+        string mlsd = await c.ListAsync("MLSD /pub");
+        Assert.Contains("type=dir;", mlsd);
+        Assert.Matches(@"type=file;size=7;modify=\d{14};perm=r; año\.txt", Encoding.UTF8.GetString(Encoding.Latin1.GetBytes(mlsd)));
+    }
+
+    [Fact]
+    public async Task RetrWithRestAndAsciiMode()
+    {
+        await using var c = await Anonymous();
+        await c.CommandAsync("CWD /pub");
+        Assert.Equal(350, (await c.CommandAsync("REST 3")).Code);
+        var (_, partial) = await c.PassiveAsync("RETR docs/manual.txt");
+        Assert.Equal("3456789", Encoding.ASCII.GetString(partial));
+
+        await c.CommandAsync("TYPE A");
+        var (_, ascii) = await c.PassiveAsync("RETR leeme.txt");
+        Assert.Equal("linea 1\r\nlinea 2\r\n", Encoding.ASCII.GetString(ascii));
+        Assert.Equal("16", (await c.CommandAsync("SIZE leeme.txt")).Text[4..]);
+        Assert.Matches(@"^213 \d{14}$", (await c.CommandAsync("MDTM leeme.txt")).Text);
+    }
+
+    [Fact]
+    public async Task UploadsRespectPermissions()
+    {
+        await using var c = await Anonymous();
+        await c.CommandAsync("CWD /rw");
+        var (code, _) = await c.PassiveAsync("STOR nuevo.txt", Encoding.ASCII.GetBytes("subido"));
+        Assert.Equal(226, code);
+        Assert.Equal("subido", File.ReadAllText(Path.Combine(_rw.Path, "nuevo.txt")));
+
+        (code, _) = await c.PassiveAsync("STOR existe.txt", Encoding.ASCII.GetBytes("pisado"));
+        Assert.Equal(550, code); // sin Overwrite
+        Assert.Equal("original", File.ReadAllText(Path.Combine(_rw.Path, "existe.txt")));
+
+        (code, _) = await c.PassiveAsync("STOR /pub/x.txt", Encoding.ASCII.GetBytes("x"));
+        Assert.Equal(550, code); // share de solo lectura
+        Assert.False(File.Exists(Path.Combine(_pub.Path, "x.txt")));
+
+        (code, _) = await c.PassiveAsync("STOR /x.txt", Encoding.ASCII.GetBytes("x"));
+        Assert.Equal(553, code); // raiz virtual
+    }
+
+    [Fact]
+    public async Task FoldersRenameAndDelete()
+    {
+        await using var c = await Anonymous();
+        await c.CommandAsync("CWD /rw");
+        Assert.Equal(257, (await c.CommandAsync("MKD carpeta")).Code);
+        Assert.True(Directory.Exists(Path.Combine(_rw.Path, "carpeta")));
+        Assert.Equal(350, (await c.CommandAsync("RNFR existe.txt")).Code);
+        Assert.Equal(250, (await c.CommandAsync("RNTO carpeta/movido.txt")).Code);
+        Assert.True(File.Exists(Path.Combine(_rw.Path, "carpeta", "movido.txt")));
+        Assert.Equal(550, (await c.CommandAsync("RMD carpeta")).Code); // no esta vacia
+        Assert.Equal(250, (await c.CommandAsync("DELE carpeta/movido.txt")).Code);
+        Assert.Equal(250, (await c.CommandAsync("RMD carpeta")).Code);
+        Assert.False(Directory.Exists(Path.Combine(_rw.Path, "carpeta")));
+
+        Assert.Equal(550, (await c.CommandAsync("DELE /pub/leeme.txt")).Code);
+        Assert.Equal(550, (await c.CommandAsync("RMD /rw")).Code); // la raiz del share no se borra
+    }
+
+    [Fact]
+    public async Task TraversalStaysInsideTheTree()
+    {
+        await using var c = await Anonymous();
+        Assert.Equal(250, (await c.CommandAsync("CWD /pub/../../..")).Code);
+        Assert.Contains("\"/\"", (await c.CommandAsync("PWD")).Text);
+        Assert.Equal(550, (await c.CommandAsync("CWD C:")).Code);
+    }
+
+    [Fact]
+    public async Task PortToAnotherAddressIsRefused()
+    {
+        await using var c = await Anonymous();
+        Assert.Equal(500, (await c.CommandAsync("PORT 10,9,8,7,200,1")).Code);
+        Assert.Equal(500, (await c.CommandAsync("PORT 127,0,0,1,0,21")).Code);
+    }
+
+    [Fact]
+    public async Task ActiveModeTransfers()
+    {
+        await using var c = await Anonymous();
+        var listener = new TcpListener(IPAddress.Loopback, 0);
+        listener.Start();
+        int port = ((IPEndPoint)listener.LocalEndpoint).Port;
+        Assert.Equal(200, (await c.CommandAsync($"PORT 127,0,0,1,{port / 256},{port % 256}")).Code);
+        await c.SendAsync("RETR /pub/docs/manual.txt");
+        using var data = await listener.AcceptTcpClientAsync();
+        Assert.Equal(150, (await c.ReadReplyAsync()).Code);
+        var ms = new MemoryStream();
+        await data.GetStream().CopyToAsync(ms);
+        Assert.Equal(226, (await c.ReadReplyAsync()).Code);
+        Assert.Equal("0123456789", Encoding.ASCII.GetString(ms.ToArray()));
+        listener.Stop();
+    }
+
+    [Fact]
+    public async Task ProfileSuffixInTheUserName()
+    {
+        await using var c = await FtpTestClient.ConnectAsync(_server.FtpPort);
+        await c.LoginAsync("anonymous+dos");
+        Assert.Contains("437", (await c.CommandAsync("STAT")).Text);
+    }
+}
diff --git a/tests/Sunfish.Tests/HostTests.cs b/tests/Sunfish.Tests/HostTests.cs
new file mode 100644
index 0000000..544151e
--- /dev/null
+++ b/tests/Sunfish.Tests/HostTests.cs
@@ -0,0 +1,175 @@
+using System.Net;
+using System.Net.Sockets;
+using Sunfish.Config;
+using Sunfish.Host;
+
+namespace Sunfish.Tests;
+
+public class ShareSettingsTests
+{
+    [Fact]
+    public void SetsOptionsByIniKey()
+    {
+        var share = new ShareConfig { Name = "x" };
+        Assert.Null(ShareSettings.TrySet(share, "upload", "yes"));
+        Assert.Null(ShareSettings.TrySet(share, "Location", "fotos/"));
+        Assert.Null(ShareSettings.TrySet(share, "Users", "ana, pepe"));
+        Assert.True(share.Upload);
+        Assert.Equal("/fotos", share.Location);
+        Assert.Equal(["ana", "pepe"], share.Users);
+
+        Assert.NotNull(ShareSettings.TrySet(share, "Upload", "quizas"));
+        Assert.NotNull(ShareSettings.TrySet(share, "Inventada", "true"));
+        Assert.NotNull(ShareSettings.TrySet(share, "Location", "/$sunfish"));
+    }
+
+    [Fact]
+    public void SuggestsFreeNamesAndLocations()
+    {
+        var config = new SunfishConfig();
+        config.Shares.Add(new ShareConfig { Name = "fotos", Location = "/fotos", Path = "x" });
+        Assert.Equal("fotos-2", ShareSettings.SuggestName(config, "fotos"));
+        Assert.Equal("/fotos-2", ShareSettings.SuggestLocation(config, "fotos"));
+        Assert.Equal("/Mis fotos", ShareSettings.SuggestLocation(config, "Mis fotos"));
+    }
+
+    [Fact]
+    public void ValidatesBeforeSaving()
+    {
+        using var dir = new TempDir();
+        var config = new SunfishConfig();
+        config.Shares.Add(new ShareConfig { Name = "fotos", Location = "/fotos", Path = dir.Path });
+
+        Assert.Null(ShareSettings.Validate(config, new ShareConfig { Name = "otro", Location = "/otro", Path = dir.Path }, null));
+        Assert.NotNull(ShareSettings.Validate(config, new ShareConfig { Name = "FOTOS", Location = "/x", Path = dir.Path }, null));
+        Assert.NotNull(ShareSettings.Validate(config, new ShareConfig { Name = "otro", Location = "/Fotos", Path = dir.Path }, null));
+        Assert.NotNull(ShareSettings.Validate(config, new ShareConfig { Name = "otro", Location = "/y", Path = Path.Combine(dir.Path, "no") }, null));
+        Assert.NotNull(ShareSettings.Validate(config, new ShareConfig { Name = "con espacio", Location = "/y", Path = dir.Path }, null));
+        // Editarse a si mismo no choca con su propio nombre ni location.
+        Assert.Null(ShareSettings.Validate(config, new ShareConfig { Name = "fotos", Location = "/fotos", Path = dir.Path }, "fotos"));
+        // Un share desactivado puede compartir location.
+        Assert.Null(ShareSettings.Validate(config, new ShareConfig { Name = "otro", Location = "/fotos", Path = dir.Path, Enabled = false }, null));
+    }
+
+    [Fact]
+    public void CloneIsIndependent()
+    {
+        var config = SunfishConfig.Parse("# comentario\n[Share a]\nLocation = /a\nPath = C:\\a\n");
+        var copy = config.Clone();
+        copy.Shares[0].Upload = true;
+        Assert.False(config.Shares[0].Upload);
+        Assert.Contains("# comentario", copy.ToText());
+    }
+}
+
+public class HostTests
+{
+    private static int FreePort()
+    {
+        var l = new TcpListener(IPAddress.Loopback, 0);
+        l.Start();
+        int port = ((IPEndPoint)l.LocalEndpoint).Port;
+        l.Stop();
+        return port;
+    }
+
+    [Fact]
+    public async Task ApplyRestartsOnPortChange()
+    {
+        var config = new SunfishConfig();
+        config.Server.Bind = [IPAddress.Loopback];
+        config.Server.HttpPort = FreePort();
+        config.Server.Ftp = false;
+        config.Server.Announce = false;
+        await using var host = new SunfishHost(config);
+        host.Start();
+        int first = host.HttpPort;
+
+        var copy = config.Clone();
+        copy.Server.HttpPort = FreePort();
+        Assert.True(await host.ApplyAsync(copy));
+        Assert.True(host.HttpRunning);
+        Assert.Equal(copy.Server.HttpPort, host.HttpPort);
+        Assert.NotEqual(first, host.HttpPort);
+    }
+
+    [Fact]
+    public async Task ApplyReportsBusyPorts()
+    {
+        var blocker = new TcpListener(IPAddress.Loopback, 0);
+        blocker.Start();
+        try
+        {
+            var config = new SunfishConfig();
+            config.Server.Bind = [IPAddress.Loopback];
+            config.Server.HttpPort = FreePort();
+            config.Server.Ftp = false;
+            config.Server.Announce = false;
+            await using var host = new SunfishHost(config);
+            host.Start();
+            var copy = config.Clone();
+            copy.Server.HttpPort = ((IPEndPoint)blocker.LocalEndpoint).Port;
+            Assert.False(await host.ApplyAsync(copy));
+            Assert.False(host.HttpRunning);
+        }
+        finally
+        {
+            blocker.Stop();
+        }
+    }
+
+    [Fact]
+    public async Task ReloadsTheFileWhenAnotherProgramChangesIt()
+    {
+        using var dir = new TempDir();
+        string path = Path.Combine(dir.Path, "sunfish.conf");
+        var config = new SunfishConfig();
+        config.Shares.Add(new ShareConfig { Name = "a", Location = "/a", Path = dir.Path });
+        config.Save(path);
+
+        await using var host = new SunfishHost(SunfishConfig.Load(path));
+        host.WatchConfigFile();
+        var changed = new TaskCompletionSource();
+        host.Changed += () => changed.TrySetResult();
+
+        File.AppendAllText(path, "\n[Share b]\nLocation = /b\nPath = " + dir.Path + "\n");
+        await changed.Task.WaitAsync(TimeSpan.FromSeconds(10));
+        Assert.Equal(["a", "b"], host.Config.Shares.Select(s => s.Name));
+    }
+
+    [Fact]
+    public async Task OverridesSurviveReloads()
+    {
+        using var dir = new TempDir();
+        string path = Path.Combine(dir.Path, "sunfish.conf");
+        new SunfishConfig().Save(path);
+        await using var host = new SunfishHost(SunfishConfig.Load(path)) { Overrides = c => c.Server.HttpPort = 9999 };
+        host.WatchConfigFile();
+        var changed = new TaskCompletionSource();
+        host.Changed += () => changed.TrySetResult();
+
+        File.AppendAllText(path, "\n[Server]\nName = Otro\n");
+        await changed.Task.WaitAsync(TimeSpan.FromSeconds(10));
+        Assert.Equal("Otro", host.Config.Server.Name);
+        Assert.Equal(9999, host.Config.Server.HttpPort);
+    }
+
+    [Fact]
+    public async Task OwnSavesAreNotReloadedTwice()
+    {
+        using var dir = new TempDir();
+        string path = Path.Combine(dir.Path, "sunfish.conf");
+        new SunfishConfig().Save(path);
+        await using var host = new SunfishHost(SunfishConfig.Load(path));
+        host.WatchConfigFile();
+        int changes = 0;
+        host.Changed += () => Interlocked.Increment(ref changes);
+
+        var copy = host.Config.Clone();
+        copy.Server.Name = "Nuevo";
+        await host.SaveAndApplyAsync(copy);
+        await Task.Delay(1200);
+        Assert.Equal(1, changes);
+        Assert.Equal("Nuevo", host.Config.Server.Name);
+    }
+}
diff --git a/tests/Sunfish.Tests/MacTests.cs b/tests/Sunfish.Tests/MacTests.cs
new file mode 100644
index 0000000..e5ab8b8
--- /dev/null
+++ b/tests/Sunfish.Tests/MacTests.cs
@@ -0,0 +1,357 @@
+using System.Text;
+using Sunfish.Config;
+using Sunfish.Mac;
+using Sunfish.Shares;
+using Xunit;
+
+namespace Sunfish.Tests;
+
+public class MacUnitTests
+{
+    internal static MacFile Sample(string name = "Lectura", byte[]? resource = null) => new()
+    {
+        Name = name,
+        Type = "TEXT",
+        Creator = "ttxt",
+        ResourceFork = resource ?? Enumerable.Range(0, 300).Select(i => (byte)(i * 7)).ToArray(),
+        Modified = new DateTime(1995, 8, 24, 10, 30, 0),
+    };
+
+    internal static async Task MacBinaryOf(MacFile file, byte[] data)
+    {
+        var ms = new MemoryStream();
+        await MacBinary.EncodeAsync(file, new MemoryStream(data), data.Length, ms, CancellationToken.None);
+        return ms.ToArray();
+    }
+
+    [Fact]
+    public async Task MacBinaryRoundTrip()
+    {
+        var data = Encoding.ASCII.GetBytes("hola desde System 7");
+        var file = Sample();
+        var encoded = await MacBinaryOf(file, data);
+        Assert.Equal(MacBinary.EncodedLength(data.Length, file.ResourceFork.Length), encoded.Length);
+        Assert.Equal(0, encoded.Length % 128);
+
+        var header = MacBinary.Parse(encoded.AsSpan(0, MacBinary.HeaderSize), encoded.Length, acceptVersion1: false);
+        Assert.NotNull(header);
+        var output = new MemoryStream();
+        var decoded = await MacBinary.DecodeAsync(new MemoryStream(encoded), header, output, CancellationToken.None);
+        Assert.Equal(data, output.ToArray());
+        Assert.Equal("Lectura", decoded.Name);
+        Assert.Equal("TEXT", decoded.Type);
+        Assert.Equal("ttxt", decoded.Creator);
+        Assert.Equal(file.ResourceFork, decoded.ResourceFork);
+        Assert.Equal(file.Modified, decoded.Modified);
+    }
+
+    [Fact]
+    public void RandomDataIsNotMacBinary()
+    {
+        var random = new Random(42);
+        for (int i = 0; i < 200; i++)
+        {
+            var bytes = new byte[4096];
+            random.NextBytes(bytes);
+            bytes[0] = 0; // lo mas parecido posible: primer byte a cero
+            Assert.Null(MacBinary.Parse(bytes.AsSpan(0, MacBinary.HeaderSize), bytes.Length, acceptVersion1: false));
+        }
+    }
+
+    [Fact]
+    public async Task CorruptedCrcIsRejected()
+    {
+        var encoded = await MacBinaryOf(Sample(), [1, 2, 3]);
+        encoded[2] ^= 0x20; // cambia el nombre
+        Assert.Null(MacBinary.Parse(encoded.AsSpan(0, MacBinary.HeaderSize), encoded.Length, acceptVersion1: false));
+    }
+
+    [Fact]
+    public void BinHexRoundTripIncludingRunMarkers()
+    {
+        var data = new byte[] { 1, 0x90, 0x90, 0x90, 0x90, 2, 2, 2, 2, 2, 2, 0x90, 3, 0, 0 };
+        var file = Sample("Datos é", resource: [0x90, 0x00, 0x90, 9, 9, 9, 9]);
+        var text = BinHex.Encode(file, data);
+        Assert.True(BinHex.LooksLikeBinHex(text));
+        Assert.All(text, b => Assert.True(b < 128));
+
+        var decoded = BinHex.Decode(text);
+        Assert.NotNull(decoded);
+        Assert.Equal(data, decoded.Value.DataFork);
+        Assert.Equal("Datos é", decoded.Value.File.Name);
+        Assert.Equal("TEXT", decoded.Value.File.Type);
+        Assert.Equal(file.ResourceFork, decoded.Value.File.ResourceFork);
+    }
+
+    [Fact]
+    public void BinHexWithBadCrcIsRejected()
+    {
+        var text = BinHex.Encode(Sample(), Encoding.ASCII.GetBytes("contenido de prueba bastante largo"));
+        int i = Array.IndexOf(text, (byte)':') + 30;
+        text[i] = text[i] == (byte)'A' ? (byte)'B' : (byte)'A';
+        Assert.Null(BinHex.Decode(text));
+    }
+
+    [Fact]
+    public void AppleDoubleRoundTrip()
+    {
+        var file = Sample();
+        var read = AppleDouble.Read(AppleDouble.Write(file), "Lectura");
+        Assert.NotNull(read);
+        Assert.Equal("TEXT", read.Type);
+        Assert.Equal("ttxt", read.Creator);
+        Assert.Equal(file.ResourceFork, read.ResourceFork);
+        Assert.Equal("._x.txt", Path.GetFileName(AppleDouble.CompanionOf(Path.Combine("d", "x.txt"))));
+    }
+
+    [Fact]
+    public void ShortNamesAreStableAndUnique()
+    {
+        Assert.True(ShortNames.Is83("README.TXT"));
+        Assert.True(ShortNames.Is83("leeme.txt"));
+        Assert.False(ShortNames.Is83("Fotos del viaje.jpeg"));
+        Assert.False(ShortNames.Is83("a.b.c"));
+
+        string alias = ShortNames.Alias("Fotos del viaje.jpeg");
+        Assert.Matches(@"^FOT[0-9A-F]{3}~1\.JPE$", alias);
+        Assert.Equal(alias, ShortNames.Alias("Fotos del viaje.jpeg"));
+        Assert.Equal("CAN", ShortNames.Alias("Canción.mp3")[..3]); // transliteracion
+
+        var names = new[] { "Fotos del viaje.jpeg", "leeme.txt", "Otro documento.txt", "Año nuevo.doc" };
+        var map = ShortNames.Map(names);
+        Assert.Equal("leeme.txt", map["leeme.txt"]);
+        Assert.Equal(names.Length, map.Values.Distinct(StringComparer.OrdinalIgnoreCase).Count());
+        Assert.All(map.Values, a => Assert.True(ShortNames.Is83(a), a));
+        // Anadir otro fichero no cambia los alias existentes.
+        var more = ShortNames.Map([.. names, "Zeta muy larga.txt"]);
+        foreach (var n in names)
+            Assert.Equal(map[n], more[n]);
+    }
+
+    [Fact]
+    public void ShortNameCollisionsGetNextNumber()
+    {
+        // Mismo prefijo y mismo hash forzado: se simula pidiendo el alias 1 de los dos.
+        var map = ShortNames.Map(["Documento largo 1.txt", "Documento largo 2.txt", "Documento largo 3.txt"]);
+        Assert.Equal(3, map.Values.Distinct(StringComparer.OrdinalIgnoreCase).Count());
+    }
+
+    [Fact]
+    public void ResolvesAliasesInAFolder()
+    {
+        using var dir = new TempDir();
+        dir.File("Informe anual 2024.txt", "x");
+        string alias = ShortNames.Map(["Informe anual 2024.txt"])["Informe anual 2024.txt"];
+        Assert.Equal("Informe anual 2024.txt", ShortNames.Resolve(dir.Path, alias.ToLowerInvariant()));
+        Assert.Null(ShortNames.Resolve(dir.Path, "NOEXIS~1.TXT"));
+    }
+
+    [Fact]
+    public void MacNamesShortenKeepingTheExtension()
+    {
+        string s = MacNames.Shorten("Un nombre de fichero demasiado largo para HFS.pdf", 31);
+        Assert.Equal(31, s.Length);
+        Assert.EndsWith(".pdf", s);
+        Assert.Equal("corto.txt", MacNames.Shorten("corto.txt", 31));
+    }
+
+    [Fact]
+    public async Task UnpacksAMacBinaryUpload()
+    {
+        using var dir = new TempDir();
+        var data = Encoding.ASCII.GetBytes("datos");
+        string path = Path.Combine(dir.Path, "Lectura.bin");
+        await File.WriteAllBytesAsync(path, await MacBinaryOf(Sample(), data));
+
+        var (result, name) = await MacFiles.TryUnpackAsync(path, overwrite: false, acceptVersion1: false, CancellationToken.None);
+        Assert.Equal(MacFiles.Unpacked.Unpacked, result);
+        Assert.Equal("Lectura", name);
+        Assert.False(File.Exists(path));
+        Assert.Equal(data, File.ReadAllBytes(Path.Combine(dir.Path, "Lectura")));
+        Assert.True(File.Exists(Path.Combine(dir.Path, "._Lectura")));
+        Assert.Equal("TEXT", MacFiles.Describe(Path.Combine(dir.Path, "Lectura")).Type);
+    }
+
+    [Fact]
+    public async Task KeepsTheEncodedFileWhenTheTargetExists()
+    {
+        using var dir = new TempDir();
+        dir.File("Lectura", "ya estaba");
+        string path = Path.Combine(dir.Path, "Lectura.bin");
+        await File.WriteAllBytesAsync(path, await MacBinaryOf(Sample(), [1, 2]));
+        var (result, _) = await MacFiles.TryUnpackAsync(path, overwrite: false, acceptVersion1: false, CancellationToken.None);
+        Assert.Equal(MacFiles.Unpacked.ExistsKept, result);
+        Assert.True(File.Exists(path));
+        Assert.Equal("ya estaba", File.ReadAllText(Path.Combine(dir.Path, "Lectura")));
+    }
+
+    [Fact]
+    public async Task OrdinaryBinFilesAreLeftAlone()
+    {
+        using var dir = new TempDir();
+        var bytes = new byte[1000];
+        new Random(1).NextBytes(bytes);
+        string path = Path.Combine(dir.Path, "firmware.bin");
+        await File.WriteAllBytesAsync(path, bytes);
+        var (result, _) = await MacFiles.TryUnpackAsync(path, overwrite: false, acceptVersion1: true, CancellationToken.None);
+        Assert.Equal(MacFiles.Unpacked.NotMac, result);
+        Assert.Equal(bytes, File.ReadAllBytes(path));
+    }
+
+    [Fact]
+    public void CompanionsFollowDeleteAndMove()
+    {
+        using var dir = new TempDir();
+        string file = dir.File("Nota", "x");
+        File.WriteAllBytes(AppleDouble.CompanionOf(file), AppleDouble.Write(Sample("Nota")));
+
+        string moved = Path.Combine(dir.Path, "Nota 2");
+        Assert.Equal(FileOpResult.Ok, FileOperations.Move(file, moved));
+        Assert.False(File.Exists(Path.Combine(dir.Path, "._Nota")));
+        Assert.True(File.Exists(Path.Combine(dir.Path, "._Nota 2")));
+
+        Assert.Equal(FileOpResult.Ok, FileOperations.Delete(moved));
+        Assert.Empty(Directory.GetFiles(dir.Path));
+    }
+}
+
+/// Mac clasico y alias 8.3 a traves de la web y el FTP reales.
+public class MacServerTests : IAsyncLifetime
+{
+    private readonly TempDir _dir = new();
+    private TestServer _server = null!;
+    private static readonly byte[] Data = Encoding.ASCII.GetBytes("contenido del data fork");
+
+    public Task InitializeAsync()
+    {
+        _dir.File("Informe anual 2024.txt", "informe");
+        string nota = _dir.File("Nota", Encoding.ASCII.GetString(Data));
+        File.WriteAllBytes(AppleDouble.CompanionOf(nota), AppleDouble.Write(MacUnitTests.Sample("Nota")));
+
+        var config = new SunfishConfig();
+        config.Shares.Add(new ShareConfig { Name = "mac", Location = "/mac", Path = _dir.Path, Upload = true, Rename = true, Delete = true });
+        _server = new TestServer(config);
+        return Task.CompletedTask;
+    }
+
+    public async Task DisposeAsync()
+    {
+        await _server.DisposeAsync();
+        _dir.Dispose();
+    }
+
+    private string Alias => ShortNames.Map(Directory.EnumerateFileSystemEntries(_dir.Path).Select(Path.GetFileName)!)["Informe anual 2024.txt"];
+
+    [Fact]
+    public async Task DosListingShowsAliasesThatResolve()
+    {
+        var page = await _server.GetAsync("/mac/?p=dos");
+        Assert.Contains(Alias, page.BodyText);
+        Assert.Contains("(Informe anual 2024.txt)", page.BodyText);
+        Assert.DoesNotContain("._Nota", page.BodyText);
+
+        var file = await _server.GetAsync("/mac/" + Alias + "?p=dos");
+        Assert.Equal(200, file.Status);
+        Assert.Equal("informe", file.BodyText);
+        Assert.Contains(Alias, file.Headers["Content-Disposition"]);
+    }
+
+    [Fact]
+    public async Task ModernListingOffersMacEncodings()
+    {
+        var page = await _server.GetAsync("/mac/");
+        Assert.Contains("Nota?as=macbin", page.BodyText);
+        Assert.Contains("Nota?as=hqx", page.BodyText);
+        Assert.DoesNotContain("Informe%20anual%202024.txt?as=macbin", page.BodyText);
+
+        var plain = await _server.GetAsync("/mac/Nota");
+        Assert.Equal(Data, plain.Body);
+    }
+
+    [Fact]
+    public async Task MacBinaryAndBinHexDownloads()
+    {
+        var bin = await _server.GetAsync("/mac/Nota?as=macbin");
+        Assert.Equal(200, bin.Status);
+        Assert.Equal("application/x-macbinary", bin.Headers["Content-Type"]);
+        Assert.Contains("Nota.bin", bin.Headers["Content-Disposition"]);
+        var header = MacBinary.Parse(bin.Body.AsSpan(0, MacBinary.HeaderSize), bin.Body.Length, false);
+        Assert.NotNull(header);
+        Assert.Equal("TEXT", header.File.Type);
+        Assert.Equal(Data.Length, header.DataLength);
+
+        var hqx = await _server.GetAsync("/mac/Nota?as=hqx");
+        var decoded = BinHex.Decode(hqx.Body);
+        Assert.NotNull(decoded);
+        Assert.Equal(Data, decoded.Value.DataFork);
+    }
+
+    [Fact]
+    public async Task ClassicMacGetsMacBinaryAutomatically()
+    {
+        var r = await _server.GetAsync("/mac/Nota?p=mac");
+        Assert.Equal("application/x-macbinary", r.Headers["Content-Type"]);
+        // Un fichero sin resource fork se entrega tal cual.
+        var plain = await _server.GetAsync("/mac/" + Uri.EscapeDataString("Informe anual 2024.txt") + "?p=mac");
+        Assert.Equal("informe", plain.BodyText);
+    }
+
+    [Fact]
+    public async Task MacBinaryUploadIsUnpacked()
+    {
+        var page = await _server.GetAsync("/mac/");
+        var token = System.Text.RegularExpressions.Regex.Match(page.BodyText,
+            "NAME=\"a\" VALUE=\"upload\">El QR propio se comprueba leyendolo con un decodificador independiente (ZXing, solo en pruebas).
+public class QrTests
+{
+    private static string? Decode(QrCode qr, int scale = 4, int quiet = 4)
+    {
+        int size = (qr.Size + quiet * 2) * scale;
+        var pixels = new byte[size * size];
+        Array.Fill(pixels, (byte)255);
+        for (int y = 0; y < qr.Size; y++)
+            for (int x = 0; x < qr.Size; x++)
+                if (qr[x, y])
+                    for (int dy = 0; dy < scale; dy++)
+                        for (int dx = 0; dx < scale; dx++)
+                            pixels[((y + quiet) * scale + dy) * size + (x + quiet) * scale + dx] = 0;
+        var source = new RGBLuminanceSource(pixels, size, size, RGBLuminanceSource.BitmapFormat.Gray8);
+        var hints = new Dictionary { [DecodeHintType.PURE_BARCODE] = true, [DecodeHintType.CHARACTER_SET] = "UTF-8" };
+        return new QRCodeReader().decode(new BinaryBitmap(new HybridBinarizer(source)), hints)?.Text;
+    }
+
+    [Theory]
+    [InlineData("http://192.168.1.20:8080/")]
+    [InlineData("http://[fe80::1234:5678:9abc:def0]:8080/fotos/")]
+    [InlineData("ftp://192.168.1.20:2121/pub/")]
+    [InlineData("Sunfish ñ é ü")]
+    public void ShortTextsDecode(string text)
+    {
+        var qr = QrCode.Encode(text);
+        Assert.Equal(text, Decode(qr));
+    }
+
+    [Theory]
+    [InlineData(50)]
+    [InlineData(150)]   // version 7+ (informacion de version)
+    [InlineData(400)]   // varios bloques de longitudes distintas
+    [InlineData(1200)]
+    public void LongerTextsDecode(int length)
+    {
+        string text = string.Concat(Enumerable.Range(0, length).Select(i => (char)('a' + i * 7 % 26)));
+        var qr = QrCode.Encode(text);
+        Assert.Equal(text, Decode(qr));
+    }
+
+    [Fact]
+    public void AllEccLevelsAndMasksDecode()
+    {
+        var masks = new HashSet();
+        foreach (var ecc in Enum.GetValues())
+            for (int i = 0; i < 40; i++)
+            {
+                string text = "http://10.0.0." + i + ":8080/" + new string('x', i);
+                var qr = QrCode.Encode(text, ecc);
+                Assert.True(qr.Ecc >= ecc);
+                masks.Add(qr.Mask);
+                Assert.Equal(text, Decode(qr));
+            }
+        Assert.True(masks.Count >= 4); // la eleccion de mascara no es siempre la misma
+    }
+
+    [Fact]
+    public void UsesTheSmallestVersionAndRaisesEcc()
+    {
+        var qr = QrCode.Encode("http://192.168.1.20:8080/");
+        Assert.Equal(2, qr.Version);
+        Assert.Equal(25, qr.Size);
+        Assert.True(qr.Ecc >= QrEcc.Medium);
+    }
+
+    [Fact]
+    public void TooLongThrows() =>
+        Assert.Throws(() => QrCode.Encode(new string('a', 3000)));
+}
diff --git a/tests/Sunfish.Tests/SharesTests.cs b/tests/Sunfish.Tests/SharesTests.cs
new file mode 100644
index 0000000..655b272
--- /dev/null
+++ b/tests/Sunfish.Tests/SharesTests.cs
@@ -0,0 +1,178 @@
+using Sunfish.Config;
+using Sunfish.Shares;
+
+namespace Sunfish.Tests;
+
+public class PathRulesTests
+{
+    [Theory]
+    [InlineData("fotos", true)]
+    [InlineData("Fotos del viaje.jpeg", true)]
+    [InlineData("año", true)]
+    [InlineData("..", false)]
+    [InlineData(".", false)]
+    [InlineData("", false)]
+    [InlineData("a\\b", false)]
+    [InlineData("a:b", false)]
+    [InlineData("a\u0001", false)]
+    public void SegmentsOnAnySystem(string segment, bool valid) =>
+        Assert.Equal(valid, PathRules.IsValidSegment(segment, windows: false));
+
+    [Theory]
+    [InlineData("CON", false)]
+    [InlineData("con.txt", false)]
+    [InlineData("LPT1", false)]
+    [InlineData("nombre.", false)]
+    [InlineData("nombre ", false)]
+    [InlineData("a*b", false)]
+    [InlineData("CONSOLA", true)]
+    [InlineData("COM10", true)]
+    public void SegmentsOnWindows(string segment, bool valid) =>
+        Assert.Equal(valid, PathRules.IsValidSegment(segment, windows: true));
+
+    [Fact]
+    public void SplitsAndDecodesUrlPaths()
+    {
+        Assert.True(PathRules.TrySplitUrlPath("/fotos//2026/a%20b.jpg", out var segments, out bool slash));
+        Assert.Equal(["fotos", "2026", "a b.jpg"], segments);
+        Assert.False(slash);
+
+        Assert.True(PathRules.TrySplitUrlPath("/", out segments, out slash));
+        Assert.Empty(segments);
+        Assert.True(slash);
+    }
+
+    [Theory]
+    [InlineData("/../secreto")]
+    [InlineData("/a/%2e%2e/b")]
+    [InlineData("/a%2fb")]
+    [InlineData("/a%5cb")]
+    [InlineData("/c%3a")]
+    public void RejectsTraversalEvenWhenEncoded(string path) =>
+        Assert.False(PathRules.TrySplitUrlPath(path, out _, out _));
+
+    [Fact]
+    public void DecodesUtf8AndFallsBackToLatin1()
+    {
+        Assert.Equal("año", PathRules.PercentDecode("a%C3%B1o"));
+        Assert.Equal("año", PathRules.PercentDecode("a%F1o")); // navegador antiguo que envia Latin-1
+    }
+
+    [Theory]
+    [InlineData("/", "/")]
+    [InlineData("fotos", "/fotos")]
+    [InlineData("/fotos/", "/fotos")]
+    [InlineData("//a//b", "/a/b")]
+    public void NormalizesLocations(string input, string expected)
+    {
+        Assert.True(LocationPath.TryNormalize(input, out var normalized));
+        Assert.Equal(expected, normalized);
+    }
+}
+
+public class ShareTreeTests
+{
+    private static ShareConfig S(string name, string location) => new() { Name = name, Location = location, Path = "x" };
+
+    private static readonly ShareTree Tree = new([
+        S("web", "/"),
+        S("fotos", "/fotos"),
+        S("viaje", "/fotos/2026/viaje"),
+        S("musica", "/Musica/clasica"),
+    ]);
+
+    [Theory]
+    [InlineData("index.html", "web", "index.html")]
+    [InlineData("fotos/a.jpg", "fotos", "a.jpg")]
+    [InlineData("fotos/2026/b.jpg", "fotos", "2026/b.jpg")]
+    [InlineData("fotos/2026/viaje/c.jpg", "viaje", "c.jpg")]
+    [InlineData("FOTOS/x", "fotos", "x")]
+    [InlineData("fotosviejas/x", "web", "fotosviejas/x")]
+    public void LongestPrefixWins(string path, string share, string relative)
+    {
+        var r = Tree.Resolve(path.Split('/'));
+        Assert.Equal(share, r.Share?.Name);
+        Assert.Equal(relative, string.Join('/', r.Relative));
+    }
+
+    [Fact]
+    public void ComparesWholeSegments()
+    {
+        var tree = new ShareTree([S("fotos", "/fotos")]);
+        Assert.Null(tree.Resolve(["foto"]).Share);
+        Assert.Null(tree.Resolve(["fotosx"]).Share);
+    }
+
+    [Fact]
+    public void ListsVirtualChildren()
+    {
+        Assert.Equal(["fotos", "Musica"], Tree.Resolve([]).VirtualChildren);
+        Assert.Equal(["2026"], Tree.Resolve(["fotos"]).VirtualChildren);
+        Assert.Equal(["viaje"], Tree.Resolve(["fotos", "2026"]).VirtualChildren);
+        Assert.Equal(["clasica"], Tree.Resolve(["musica"]).VirtualChildren);
+    }
+
+    [Fact]
+    public void IgnoresDisabledShares()
+    {
+        var off = S("off", "/off");
+        off.Enabled = false;
+        var tree = new ShareTree([off]);
+        Assert.Null(tree.Resolve(["off"]).Share);
+        Assert.Empty(tree.Resolve([]).VirtualChildren);
+    }
+}
+
+public class ShareFileSystemTests
+{
+    [Fact]
+    public void MapsInsideTheShare()
+    {
+        using var dir = new TempDir();
+        dir.File("a/b.txt", "x");
+        var share = new ShareConfig { Path = dir.Path };
+        string? physical = ShareFileSystem.TryGetPhysicalPath(share, ["a", "b.txt"]);
+        Assert.Equal(Path.Combine(dir.Path, "a", "b.txt"), physical);
+    }
+
+    [Fact]
+    public void RejectsEscapes()
+    {
+        using var dir = new TempDir();
+        var share = new ShareConfig { Path = dir.Path };
+        Assert.Null(ShareFileSystem.TryGetPhysicalPath(share, [".."]));
+        Assert.Null(ShareFileSystem.TryGetPhysicalPath(share, ["a", "..", ".."]));
+        Assert.Null(ShareFileSystem.TryGetPhysicalPath(share, ["C:"]));
+    }
+
+    [Fact]
+    public void DoesNotFollowLinksOutOfTheShare()
+    {
+        using var outside = new TempDir();
+        using var dir = new TempDir();
+        string link = Path.Combine(dir.Path, "salida");
+        try
+        {
+            Directory.CreateSymbolicLink(link, outside.Path);
+        }
+        catch (Exception e) when (e is UnauthorizedAccessException or IOException)
+        {
+            return; // Windows sin modo desarrollador no deja crear enlaces simbolicos
+        }
+        outside.File("secreto.txt", "x");
+        var share = new ShareConfig { Path = dir.Path };
+        Assert.Null(ShareFileSystem.TryGetPhysicalPath(share, ["salida"]));
+        Assert.Null(ShareFileSystem.TryGetPhysicalPath(share, ["salida", "secreto.txt"]));
+    }
+
+    [Fact]
+    public void ListingSkipsHiddenEntries()
+    {
+        using var dir = new TempDir();
+        dir.File("visible.txt");
+        dir.File(".oculto");
+        dir.Folder("carpeta");
+        var names = ShareFileSystem.List(dir.Path).Select(e => e.Name).Order().ToList();
+        Assert.Equal(["carpeta", "visible.txt"], names);
+    }
+}
diff --git a/tests/Sunfish.Tests/Sunfish.Tests.csproj b/tests/Sunfish.Tests/Sunfish.Tests.csproj
new file mode 100644
index 0000000..81e9827
--- /dev/null
+++ b/tests/Sunfish.Tests/Sunfish.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+  
+    net10.0
+    enable
+    enable
+    false
+    true
+  
+
+  
+    
+    
+    
+    
+  
+
+  
+    
+  
+
+  
+    
+  
+
+
diff --git a/tests/Sunfish.Tests/TestSupport.cs b/tests/Sunfish.Tests/TestSupport.cs
new file mode 100644
index 0000000..6b8b20a
--- /dev/null
+++ b/tests/Sunfish.Tests/TestSupport.cs
@@ -0,0 +1,140 @@
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using Sunfish.Config;
+using Sunfish.Host;
+
+namespace Sunfish.Tests;
+
+/// 
+/// Las variables de entorno son del proceso entero: las pruebas que las tocan
+/// comparten esta coleccion para que xUnit no las ejecute en paralelo.
+/// 
+[CollectionDefinition(Name, DisableParallelization = true)]
+public sealed class EnvironmentCollection
+{
+    public const string Name = "Environment";
+}
+
+/// Fija variables de entorno y las restaura al terminar.
+internal sealed class EnvScope : IDisposable
+{
+    private readonly Dictionary _saved = new();
+
+    public EnvScope Set(string name, string? value)
+    {
+        if (!_saved.ContainsKey(name))
+            _saved[name] = Environment.GetEnvironmentVariable(name);
+        Environment.SetEnvironmentVariable(name, value);
+        return this;
+    }
+
+    public void Dispose()
+    {
+        foreach (var (name, value) in _saved)
+            Environment.SetEnvironmentVariable(name, value);
+    }
+}
+
+/// Carpeta temporal que se borra al terminar.
+internal sealed class TempDir : IDisposable
+{
+    public string Path { get; } =
+        System.IO.Path.Combine(System.IO.Path.GetTempPath(), "sunfish-tests-" + Guid.NewGuid().ToString("N"));
+
+    public TempDir() => Directory.CreateDirectory(Path);
+
+    public string File(string name, string content = "")
+    {
+        string full = System.IO.Path.Combine(Path, name);
+        Directory.CreateDirectory(System.IO.Path.GetDirectoryName(full)!);
+        System.IO.File.WriteAllText(full, content);
+        return full;
+    }
+
+    public string Folder(string name)
+    {
+        string full = System.IO.Path.Combine(Path, name);
+        Directory.CreateDirectory(full);
+        return full;
+    }
+
+    public void Dispose()
+    {
+        try { Directory.Delete(Path, recursive: true); }
+        catch (IOException) { }
+        catch (UnauthorizedAccessException) { }
+    }
+}
+
+/// Servidor real en 127.0.0.1 con un puerto libre, y un cliente HTTP en bruto.
+internal sealed class TestServer : IAsyncDisposable
+{
+    private readonly SunfishHost _host;
+
+    public TestServer(SunfishConfig config)
+    {
+        config.Server.Bind = [IPAddress.Loopback];
+        config.Server.HttpPort = 0;
+        config.Server.FtpPort = 0;
+        config.Server.Announce = false;
+        _host = new SunfishHost(config);
+        _host.Start();
+    }
+
+    public SunfishHost Host => _host;
+    public int Port => _host.HttpPort;
+    public int FtpPort => _host.FtpPort;
+
+    /// Envia la peticion tal cual y devuelve todo lo recibido hasta el cierre.
+    public async Task SendAsync(string request)
+    {
+        using var client = new TcpClient();
+        await client.ConnectAsync(IPAddress.Loopback, Port);
+        var stream = client.GetStream();
+        await stream.WriteAsync(Encoding.Latin1.GetBytes(request));
+        var ms = new MemoryStream();
+        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+        await stream.CopyToAsync(ms, cts.Token);
+        return new RawResponse(ms.ToArray());
+    }
+
+    /// GET con HTTP/1.0 y cierre de conexion.
+    public Task GetAsync(string path, string? userAgent = null, string extraHeaders = "") =>
+        SendAsync($"GET {path} HTTP/1.0\r\n" +
+                  (userAgent is null ? "" : $"User-Agent: {userAgent}\r\n") +
+                  extraHeaders + "\r\n");
+
+    public async ValueTask DisposeAsync() => await _host.DisposeAsync();
+}
+
+internal sealed class RawResponse
+{
+    public RawResponse(byte[] data)
+    {
+        Data = data;
+        string text = Encoding.Latin1.GetString(data);
+        int end = text.IndexOf("\r\n\r\n", StringComparison.Ordinal);
+        if (text.StartsWith("HTTP/", StringComparison.Ordinal) && end >= 0)
+        {
+            var lines = text[..end].Split("\r\n");
+            Status = int.Parse(lines[0].Split(' ')[1]);
+            foreach (var line in lines.Skip(1))
+            {
+                int colon = line.IndexOf(':');
+                Headers[line[..colon].Trim()] = line[(colon + 1)..].Trim();
+            }
+            Body = data[(end + 4)..];
+        }
+        else
+        {
+            Body = data; // HTTP/0.9: sin cabeceras
+        }
+    }
+
+    public byte[] Data { get; }
+    public int Status { get; }
+    public Dictionary Headers { get; } = new(StringComparer.OrdinalIgnoreCase);
+    public byte[] Body { get; }
+    public string BodyText => Encoding.Latin1.GetString(Body);
+}
diff --git a/tests/Sunfish.Tests/WebServerTests.cs b/tests/Sunfish.Tests/WebServerTests.cs
new file mode 100644
index 0000000..eb3f888
--- /dev/null
+++ b/tests/Sunfish.Tests/WebServerTests.cs
@@ -0,0 +1,209 @@
+using System.Text;
+using Sunfish.Config;
+
+namespace Sunfish.Tests;
+
+/// Servidor real sobre localhost, hablando HTTP en bruto como lo haria un cliente antiguo.
+public class WebServerTests : IAsyncLifetime
+{
+    private const string Modern = "Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0";
+
+    private readonly TempDir _web = new();
+    private readonly TempDir _fotos = new();
+    private TestServer _server = null!;
+
+    public Task InitializeAsync()
+    {
+        _web.File("index.html", "portada");
+        _web.File("leeme.txt", "hola");
+        _web.File("docs/manual.txt", "0123456789");
+        _web.File("sitio/index.html", "sitio");
+        _web.File("fotos/tapada.txt", "no deberia verse");
+        _fotos.File("año 2026.jpg", "jpeg");
+        _fotos.File(".oculto", "x");
+
+        var config = new SunfishConfig();
+        config.Shares.Add(new ShareConfig { Name = "web", Location = "/", Path = _web.Path });
+        config.Shares.Add(new ShareConfig { Name = "fotos", Location = "/fotos", Path = _fotos.Path });
+        config.Shares.Add(new ShareConfig { Name = "privado", Location = "/privado", Path = _fotos.Path, Listing = false, Index = "" });
+        config.Shares.Add(new ShareConfig { Name = "hondo", Location = "/a/b/c", Path = _fotos.Path });
+        _server = new TestServer(config);
+        return Task.CompletedTask;
+    }
+
+    public async Task DisposeAsync()
+    {
+        await _server.DisposeAsync();
+        _web.Dispose();
+        _fotos.Dispose();
+    }
+
+    [Fact]
+    public async Task ServesIndexAtRoot()
+    {
+        var r = await _server.GetAsync("/");
+        Assert.Equal(200, r.Status);
+        Assert.Equal("portada", r.BodyText);
+        Assert.Equal("text/html", r.Headers["Content-Type"]);
+    }
+
+    [Fact]
+    public async Task ListParameterShowsTheFolderDespiteTheIndex()
+    {
+        var r = await _server.GetAsync("/?$list");
+        Assert.Equal(200, r.Status);
+        Assert.Contains("leeme.txt", r.BodyText);
+        Assert.Contains("HREF=\"sitio/\"", r.BodyText);
+        Assert.Contains("sitio/?$list", r.BodyText); // carpeta con indice: enlace [contenido]
+    }
+
+    [Fact]
+    public async Task LongerLocationHidesTheRealFolder()
+    {
+        var r = await _server.GetAsync("/fotos/");
+        Assert.Equal(200, r.Status);
+        Assert.Contains("a%C3%B1o%202026.jpg", r.BodyText);
+        Assert.DoesNotContain("tapada", r.BodyText);
+        Assert.DoesNotContain(".oculto", r.BodyText);
+    }
+
+    [Fact]
+    public async Task ClassicPagesAreLatin1WithEntities()
+    {
+        var r = await _server.GetAsync("/fotos/", "Mozilla/3.01 (Win95; I)");
+        Assert.Equal("text/html; charset=iso-8859-1", r.Headers["Content-Type"]);
+        Assert.Contains("año 2026.jpg", r.BodyText);
+        Assert.DoesNotContain(" Assert.True(b < 0x80));
+    }
+
+    [Fact]
+    public async Task ModernPagesAreUtf8()
+    {
+        var r = await _server.GetAsync("/fotos/", Modern);
+        Assert.Equal("text/html; charset=utf-8", r.Headers["Content-Type"]);
+        Assert.Contains("año 2026.jpg", Encoding.UTF8.GetString(r.Body));
+        Assert.Contains("font-family:Verdana", r.BodyText);
+    }
+
+    [Fact]
+    public async Task ForcedProfileIsPropagatedInLinks()
+    {
+        var r = await _server.GetAsync("/?$list&p=dos");
+        Assert.Contains("HREF=\"docs/?p=dos\"", r.BodyText);
+        Assert.Contains("
    ", r.BodyText); // DOS: lista apta para navegadores de texto + } + + [Fact] + public async Task DownloadsFilesWithLatin1Url() + { + var r = await _server.GetAsync("/fotos/a%F1o%202026.jpg"); + Assert.Equal(200, r.Status); + Assert.Equal("jpeg", r.BodyText); + Assert.Equal("image/jpeg", r.Headers["Content-Type"]); + Assert.Equal("4", r.Headers["Content-Length"]); + } + + [Fact] + public async Task RedirectsFoldersWithoutSlash() + { + var r = await _server.GetAsync("/docs", extraHeaders: "Host: equipo:8080\r\n"); + Assert.Equal(301, r.Status); + Assert.Equal("http://equipo:8080/docs/", r.Headers["Location"]); + } + + [Fact] + public async Task VirtualIntermediateFolders() + { + var r = await _server.GetAsync("/a/b/"); + Assert.Equal(200, r.Status); + Assert.Contains("HREF=\"c/\"", r.BodyText); + } + + [Fact] + public async Task ForbiddenWhenListingIsOff() + { + Assert.Equal(403, (await _server.GetAsync("/privado/")).Status); + Assert.Equal(200, (await _server.GetAsync("/privado/a%C3%B1o%202026.jpg")).Status); + } + + [Theory] + [InlineData("/../../Windows/win.ini")] + [InlineData("/%2e%2e/%2e%2e/etc/passwd")] + [InlineData("/fotos/..%5c..%5csecreto")] + public async Task TraversalIsRejected(string path) + { + var r = await _server.GetAsync(path); + Assert.Equal(400, r.Status); + } + + [Fact] + public async Task NotFound() => Assert.Equal(404, (await _server.GetAsync("/no/existe.txt")).Status); + + [Fact] + public async Task Http09GetsOnlyTheBody() + { + var r = await _server.SendAsync("GET /leeme.txt\r\n"); + Assert.Equal("hola", Encoding.ASCII.GetString(r.Data)); + } + + [Fact] + public async Task HeadHasNoBody() + { + var r = await _server.SendAsync("HEAD /docs/manual.txt HTTP/1.0\r\n\r\n"); + Assert.Equal(200, r.Status); + Assert.Equal("10", r.Headers["Content-Length"]); + Assert.Empty(r.Body); + } + + [Fact] + public async Task RangeRequests() + { + var r = await _server.GetAsync("/docs/manual.txt", extraHeaders: "Range: bytes=3-5\r\n"); + Assert.Equal(206, r.Status); + Assert.Equal("345", r.BodyText); + Assert.Equal("bytes 3-5/10", r.Headers["Content-Range"]); + + r = await _server.GetAsync("/docs/manual.txt", extraHeaders: "Range: bytes=50-\r\n"); + Assert.Equal(416, r.Status); + } + + [Fact] + public async Task NotModified() + { + var first = await _server.GetAsync("/leeme.txt"); + var r = await _server.GetAsync("/leeme.txt", extraHeaders: $"If-Modified-Since: {first.Headers["Last-Modified"]}\r\n"); + Assert.Equal(304, r.Status); + Assert.Empty(r.Body); + } + + [Fact] + public async Task KeepAliveServesSeveralRequests() + { + var r = await _server.SendAsync( + "GET /leeme.txt HTTP/1.1\r\nHost: x\r\n\r\n" + + "GET /docs/manual.txt HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); + string text = r.BodyText; + Assert.Contains("hola", text); + Assert.EndsWith("0123456789", Encoding.ASCII.GetString(r.Data)); + } + + [Fact] + public async Task ServesEmbeddedResources() + { + var r = await _server.GetAsync("/$sunfish/res/logo.gif"); + Assert.Equal(200, r.Status); + Assert.Equal("image/gif", r.Headers["Content-Type"]); + Assert.StartsWith("GIF8", r.BodyText); + Assert.Equal(200, (await _server.GetAsync("/favicon.ico")).Status); + } + + [Fact] + public async Task UnknownMethodsAndDeleteWithoutPermission() + { + var r = await _server.SendAsync("DELETE /leeme.txt HTTP/1.0\r\n\r\n"); + Assert.Equal(403, r.Status); // WebDAV DELETE sin permiso de borrar + Assert.True(File.Exists(Path.Combine(_web.Path, "leeme.txt"))); + Assert.Equal(405, (await _server.SendAsync("TRACE / HTTP/1.0\r\n\r\n")).Status); + } +} diff --git a/tests/Sunfish.Tests/WebUnitTests.cs b/tests/Sunfish.Tests/WebUnitTests.cs new file mode 100644 index 0000000..6826ec4 --- /dev/null +++ b/tests/Sunfish.Tests/WebUnitTests.cs @@ -0,0 +1,163 @@ +using System.Text; +using Sunfish.Http; +using Sunfish.Web; + +namespace Sunfish.Tests; + +public class HtmlBuilderTests +{ + [Fact] + public void ClassicModeIsPureAscii() + { + string escaped = HtmlBuilder.Escape("Año <ñ> & \"€\" ő 日", classic: true); + Assert.Equal("Año <ñ> & "?" o ?", escaped); + Assert.All(escaped, c => Assert.True(c < 0x80)); + } + + [Fact] + public void ModernModeKeepsUnicode() => + Assert.Equal("Año <日>", HtmlBuilder.Escape("Año <日>", classic: false)); + + [Fact] + public void UrlSegmentsAreUtf8Percent() => + Assert.Equal("Fotos%20del%20a%C3%B1o.jpg", HtmlBuilder.UrlSegment("Fotos del año.jpg")); +} + +public class ClientProfileTests +{ + [Theory] + [InlineData("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/130 Safari/537.36", ClientKind.Modern)] + [InlineData("Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0", ClientKind.Modern)] + [InlineData("Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 Safari/605.1.15", ClientKind.Modern)] + [InlineData("Mozilla/4.0 (compatible; MSIE 5.5; Windows 98)", ClientKind.Classic)] + [InlineData("Mozilla/3.01 (Win95; I)", ClientKind.Classic)] + [InlineData("Mozilla/3.0 (Win16; I)", ClientKind.Win16)] + [InlineData("Mozilla/2.0 (compatible; MSIE 3.0; Windows 3.1)", ClientKind.Win16)] + [InlineData("Mozilla/3.0 (Macintosh; I; PPC)", ClientKind.Mac)] + [InlineData("Mozilla/4.0 (compatible; MSIE 5.0; Mac_PowerPC)", ClientKind.Mac)] + [InlineData("Lynx/2.8.9rel.1 libwww-FM/2.14", ClientKind.Text)] + [InlineData("xChaos_Arachne/5.1.89;GPL,386+", ClientKind.Dos)] + [InlineData(null, ClientKind.Classic)] + public void DetectsFromUserAgent(string? userAgent, ClientKind expected) + { + var profile = ClientProfile.Detect(userAgent, null); + Assert.Equal(expected, profile.Kind); + Assert.False(profile.Forced); + } + + [Fact] + public void QueryForcesTheProfile() + { + var profile = ClientProfile.Detect("Mozilla/5.0 (X11) Gecko/20100101 Firefox/130.0", "dos"); + Assert.Equal(ClientKind.Dos, profile.Kind); + Assert.True(profile.Forced); + Assert.Equal(ClientKind.Modern, ClientProfile.Detect("Mozilla/5.0 Gecko/1", "nonsense").Kind); + } +} + +public class ByteRangeTests +{ + [Theory] + [InlineData("bytes=0-99", 1000, 0, 99)] + [InlineData("bytes=500-", 1000, 500, 999)] + [InlineData("bytes=-100", 1000, 900, 999)] + [InlineData("bytes=900-5000", 1000, 900, 999)] + [InlineData("bytes=-5000", 1000, 0, 999)] + public void ValidRanges(string header, long size, long start, long end) + { + Assert.Equal(ByteRange.ParseResult.Valid, ByteRange.TryParse(header, size, out var range)); + Assert.Equal(new ByteRange(start, end), range); + } + + [Theory] + [InlineData(null)] + [InlineData("items=0-1")] + [InlineData("bytes=0-1,5-6")] + [InlineData("bytes=9-3")] + [InlineData("bytes=a-b")] + public void IgnoredRanges(string? header) => + Assert.Equal(ByteRange.ParseResult.None, ByteRange.TryParse(header, 1000, out _)); + + [Fact] + public void RangeBeyondTheEnd() => + Assert.Equal(ByteRange.ParseResult.Unsatisfiable, ByteRange.TryParse("bytes=1000-", 1000, out _)); +} + +public class HttpRequestReaderTests +{ + private static async Task Read(string raw) + { + var reader = new HttpRequestReader(new MemoryStream(Encoding.Latin1.GetBytes(raw))); + return await reader.ReadRequestAsync(() => Task.CompletedTask, CancellationToken.None); + } + + [Fact] + public async Task ParsesHttp11() + { + var r = await Read("GET /a%20b?x=1&y HTTP/1.1\r\nHost: h\r\nUser-Agent: test\r\n\r\n"); + Assert.NotNull(r); + Assert.Equal("GET", r.Method); + Assert.Equal("/a%20b", r.Path); + Assert.Equal("1", r.GetQuery("x")); + Assert.True(r.HasQuery("y")); + Assert.True(r.IsHttp11OrLater); + Assert.True(r.WantsKeepAlive); + Assert.Equal("test", r.Headers["user-agent"]); + } + + [Fact] + public async Task AcceptsBareLineFeedsAndNoHost() + { + var r = await Read("\n\nGET / HTTP/1.0\nAccept: */*\n\n"); + Assert.NotNull(r); + Assert.Equal("/", r.Path); + Assert.False(r.WantsKeepAlive); + Assert.Null(r.Headers["Host"]); + } + + [Fact] + public async Task ParsesHttp09() + { + var r = await Read("GET /index.html\r\n"); + Assert.NotNull(r); + Assert.True(r.IsHttp09); + Assert.Equal("/index.html", r.Path); + } + + [Fact] + public async Task FoldedHeadersAndJunkLines() + { + var r = await Read("GET / HTTP/1.0\r\nX-Long: a\r\n b\r\nbasura sin dos puntos\r\n\r\n"); + Assert.Equal("a b", r!.Headers["X-Long"]); + } + + [Fact] + public async Task AbsoluteTargetAndRawHighBytes() + { + var r = await Read("GET http://host:8080/año/?q HTTP/1.0\r\n\r\n"); + Assert.Equal("/a%F1o/", r!.Path); + Assert.Equal("q", r.QueryString); + } + + [Theory] + [InlineData("POST /x\r\n", 400)] + [InlineData("GET / HTTP/2.0\r\n\r\n", 505)] + [InlineData("GET / HTTP/x\r\n\r\n", 400)] + public async Task RejectsMalformedRequests(string raw, int status) + { + var e = await Assert.ThrowsAsync(() => Read(raw)); + Assert.Equal(status, e.Status); + } + + [Fact] + public async Task ReadsChunkedBodies() + { + var r = await Read("PUT /x HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n6;ext=1\r\n world\r\n0\r\n\r\n"); + var ms = new MemoryStream(); + await r!.Body.CopyToAsync(ms); + Assert.Equal("hello world", Encoding.ASCII.GetString(ms.ToArray())); + } + + [Fact] + public async Task ReturnsNullOnCleanClose() => Assert.Null(await Read("")); +} diff --git a/tests/Sunfish.Tests/WebWriteTests.cs b/tests/Sunfish.Tests/WebWriteTests.cs new file mode 100644 index 0000000..73483cb --- /dev/null +++ b/tests/Sunfish.Tests/WebWriteTests.cs @@ -0,0 +1,205 @@ +using System.IO.Compression; +using System.Text; +using System.Text.RegularExpressions; +using Sunfish.Auth; +using Sunfish.Config; + +namespace Sunfish.Tests; + +/// Escritura por la web contra un servidor real: formularios, PUT, ZIP y usuarios. +public class WebWriteTests : IAsyncLifetime +{ + private readonly TempDir _rw = new(); + private readonly TempDir _ro = new(); + private readonly TempDir _priv = new(); + private TestServer _server = null!; + + private static readonly string AnaAuth = "Authorization: Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("ana:1234")) + "\r\n"; + + public Task InitializeAsync() + { + _rw.File("existe.txt", "original"); + _rw.File("carpeta/dentro.txt", "x"); + _ro.File("solo.txt", "lectura"); + _priv.File("secreto.txt", "s"); + + var config = new SunfishConfig(); + config.Users.Add(new UserConfig { Name = "ana", PasswordHash = Passwords.Hash("1234", 1000) }); + config.Shares.Add(new ShareConfig { Name = "rw", Location = "/rw", Path = _rw.Path, Upload = true, Rename = true, Delete = true, Zip = true }); + config.Shares.Add(new ShareConfig { Name = "ro", Location = "/ro", Path = _ro.Path }); + config.Shares.Add(new ShareConfig { Name = "priv", Location = "/priv", Path = _priv.Path, Users = ["ana"] }); + _server = new TestServer(config); + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + await _server.DisposeAsync(); + _rw.Dispose(); + _ro.Dispose(); + _priv.Dispose(); + } + + private async Task TokenAsync(string folder, string action) + { + var page = await _server.GetAsync(folder); + var match = Regex.Match(page.BodyText, $"NAME=\"a\" VALUE=\"{action}\"> PostFormAsync(string folder, string body, string version = "HTTP/1.0") => + _server.SendAsync($"POST {folder} {version}\r\nHost: h\r\nConnection: close\r\nContent-Type: application/x-www-form-urlencoded\r\n" + + $"Content-Length: {Encoding.Latin1.GetByteCount(body)}\r\n\r\n{body}"); + + private Task PostMultipartAsync(string folder, string token, string fileName, string content) + { + string body = + "--B0und\r\nContent-Disposition: form-data; name=\"a\"\r\n\r\nupload\r\n" + + $"--B0und\r\nContent-Disposition: form-data; name=\"t\"\r\n\r\n{token}\r\n" + + $"--B0und\r\nContent-Disposition: form-data; name=\"f\"; filename=\"{fileName}\"\r\nContent-Type: application/octet-stream\r\n\r\n{content}\r\n" + + "--B0und--\r\n"; + return _server.SendAsync($"POST {folder} HTTP/1.0\r\nContent-Type: multipart/form-data; boundary=B0und\r\n" + + $"Content-Length: {Encoding.Latin1.GetByteCount(body)}\r\n\r\n{body}"); + } + + [Fact] + public async Task ListingShowsFormsOnlyWhenAllowed() + { + var rw = await _server.GetAsync("/rw/"); + Assert.Contains("ENCTYPE=\"multipart/form-data\"", rw.BodyText); + Assert.Contains("?$rename=existe.txt", rw.BodyText); + Assert.Contains("?$delete=existe.txt", rw.BodyText); + Assert.Contains("?$zip", rw.BodyText); + + var ro = await _server.GetAsync("/ro/"); + Assert.DoesNotContain(" e.FullName == "carpeta/dentro.txt"); + Assert.Equal(403, (await _server.GetAsync("/ro/?$zip")).Status); + } + + [Fact] + public async Task ProtectedSharesNeedBasicAuth() + { + var r = await _server.GetAsync("/priv/secreto.txt"); + Assert.Equal(401, r.Status); + Assert.StartsWith("Basic realm=", r.Headers["WWW-Authenticate"]); + + r = await _server.GetAsync("/priv/secreto.txt", extraHeaders: AnaAuth); + Assert.Equal(200, r.Status); + Assert.Equal("s", r.BodyText); + + string wrong = "Authorization: Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("ana:mal")) + "\r\n"; + Assert.Equal(401, (await _server.GetAsync("/priv/", extraHeaders: wrong)).Status); + } +} diff --git a/tests/Sunfish.Tests/WriteUnitTests.cs b/tests/Sunfish.Tests/WriteUnitTests.cs new file mode 100644 index 0000000..fe507f7 --- /dev/null +++ b/tests/Sunfish.Tests/WriteUnitTests.cs @@ -0,0 +1,257 @@ +using System.IO.Compression; +using System.Net; +using System.Text; +using Sunfish.Auth; +using Sunfish.Config; +using Sunfish.Shares; +using Sunfish.Web; + +namespace Sunfish.Tests; + +public class AuthTests +{ + [Fact] + public void HashesVerifyAndAreSalted() + { + string a = Passwords.Hash("secreto", 1000); + string b = Passwords.Hash("secreto", 1000); + Assert.NotEqual(a, b); + Assert.StartsWith("pbkdf2-sha256$1000$", a); + Assert.True(Passwords.Verify("secreto", a)); + Assert.False(Passwords.Verify("Secreto", a)); + Assert.False(Passwords.Verify("secreto", "basura")); + } + + [Fact] + public void ParsesBasicHeaders() + { + string header = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("ana:pa:ss ñ")); + Assert.True(Authenticator.TryParseBasic(header, out var user, out var password)); + Assert.Equal("ana", user); + Assert.Equal("pa:ss ñ", password); + + header = "Basic " + Convert.ToBase64String(Encoding.Latin1.GetBytes("ana:ñ")); + Assert.True(Authenticator.TryParseBasic(header, out _, out password)); + Assert.Equal("ñ", password); + + Assert.False(Authenticator.TryParseBasic("Bearer x", out _, out _)); + Assert.False(Authenticator.TryParseBasic("Basic !!!", out _, out _)); + } + + [Fact] + public void ChecksUsersListsAndBlocksAfterFailures() + { + var now = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + var auth = new Authenticator { Now = () => now }; + var users = new[] { new UserConfig { Name = "ana", PasswordHash = Passwords.Hash("1234", 1000) } }; + var ip = IPAddress.Parse("10.0.0.5"); + + Assert.Equal(AuthResult.Ok, auth.Check(users, ["ana"], "ANA", "1234", ip)); + Assert.Equal(AuthResult.Wrong, auth.Check(users, ["pepe"], "ana", "1234", ip)); // no esta en el share + Assert.Equal(AuthResult.Missing, auth.Check(users, ["ana"], null, null, ip)); + + for (int i = 0; i < Authenticator.MaxFailures; i++) + auth.Check(users, ["ana"], "ana", "mal", ip); + Assert.Equal(AuthResult.Blocked, auth.Check(users, ["ana"], "ana", "1234", ip)); + Assert.Equal(AuthResult.Ok, auth.Check(users, ["ana"], "ana", "1234", IPAddress.Parse("10.0.0.6"))); + + now += Authenticator.BlockTime + TimeSpan.FromSeconds(1); + Assert.Equal(AuthResult.Ok, auth.Check(users, ["ana"], "ana", "1234", ip)); + } +} + +public class CsrfTokensTests +{ + [Fact] + public void TokensDependOnActionFolderAndTime() + { + var now = new DateTime(2026, 1, 1, 12, 30, 0, DateTimeKind.Utc); + var tokens = new CsrfTokens { Now = () => now }; + string t = tokens.Create("upload", "/fotos"); + + Assert.True(tokens.Validate(t, "upload", "/fotos")); + Assert.False(tokens.Validate(t, "delete", "/fotos")); + Assert.False(tokens.Validate(t, "upload", "/otra")); + Assert.False(tokens.Validate(null, "upload", "/fotos")); + Assert.False(new CsrfTokens { Now = () => now }.Validate(t, "upload", "/fotos")); // otra ejecucion + + now = now.AddHours(1); + Assert.True(tokens.Validate(t, "upload", "/fotos")); + now = now.AddHours(1); + Assert.False(tokens.Validate(t, "upload", "/fotos")); + } +} + +/// Devuelve los datos de pocos bytes en pocos bytes, para partir los delimitadores. +internal sealed class TrickleStream(byte[] data, int chunk) : MemoryStream(data) +{ + public override ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) => + base.ReadAsync(buffer[..Math.Min(chunk, buffer.Length)], ct); +} + +public class MultipartReaderTests +{ + private const string Body = + "preambulo ignorado\r\n" + + "--XyZ\r\n" + + "Content-Disposition: form-data; name=\"a\"\r\n\r\n" + + "upload\r\n" + + "--XyZ\r\n" + + "Content-Disposition: form-data; name=\"f\"; filename=\"C:\\WINDOWS\\a;b.txt\"\r\n" + + "Content-Type: text/plain\r\n\r\n" + + "linea 1\r\nx--XyZ no es\r\nfin" + + "\r\n--XyZ\n" + + "Content-Disposition: form-data; name=\"vacio\"; filename=\"\"\n\n" + + "\r\n--XyZ--\r\n"; + + [Theory] + [InlineData(1)] + [InlineData(3)] + [InlineData(7)] + [InlineData(4096)] + public async Task ReadsSectionsWithSplitDelimiters(int chunk) + { + var reader = new MultipartReader(new TrickleStream(Encoding.Latin1.GetBytes(Body), chunk), "XyZ"); + + var a = await reader.NextAsync(CancellationToken.None); + Assert.Equal("a", a!.Name); + Assert.Null(a.FileName); + Assert.Equal("upload", await MultipartReader.ReadFieldAsync(a, 100, CancellationToken.None)); + + var f = await reader.NextAsync(CancellationToken.None); + Assert.Equal("C:\\WINDOWS\\a;b.txt", f!.FileName); + Assert.Equal("a;b.txt", FileOperations.CleanUploadName(f.FileName!)); + var ms = new MemoryStream(); + await f.Body.CopyToAsync(ms); + Assert.Equal("linea 1\r\nx--XyZ no es\r\nfin", Encoding.Latin1.GetString(ms.ToArray())); + + var empty = await reader.NextAsync(CancellationToken.None); + Assert.Equal("", empty!.FileName); + + Assert.Null(await reader.NextAsync(CancellationToken.None)); + } + + [Fact] + public async Task UnreadSectionsAreSkipped() + { + var reader = new MultipartReader(new MemoryStream(Encoding.Latin1.GetBytes(Body)), "XyZ"); + await reader.NextAsync(CancellationToken.None); + var f = await reader.NextAsync(CancellationToken.None); + Assert.Equal("f", f!.Name); + } + + [Fact] + public async Task TruncatedBodiesFail() + { + var reader = new MultipartReader(new MemoryStream(Encoding.Latin1.GetBytes("--XyZ\r\nContent-Disposition: form-data; name=\"a\"\r\n\r\nsin fin")), "XyZ"); + var a = await reader.NextAsync(CancellationToken.None); + await Assert.ThrowsAsync(() => MultipartReader.ReadFieldAsync(a!, 100, CancellationToken.None)); + } + + [Theory] + [InlineData("multipart/form-data; boundary=abc", "abc")] + [InlineData("multipart/form-data; boundary=\"a b\"", "a b")] + [InlineData("application/x-www-form-urlencoded", null)] + [InlineData(null, null)] + public void ExtractsBoundaries(string? contentType, string? expected) => + Assert.Equal(expected, MultipartReader.GetBoundary(contentType)); +} + +public class FileOperationsTests +{ + [Fact] + public async Task SaveRespectsOverwriteAndLeavesNoTemporaries() + { + using var dir = new TempDir(); + Assert.Equal(FileOpResult.Ok, await FileOperations.SaveAsync(dir.Path, "a.txt", Stream("uno"), false, default)); + Assert.Equal(FileOpResult.Exists, await FileOperations.SaveAsync(dir.Path, "a.txt", Stream("dos"), false, default)); + Assert.Equal("uno", File.ReadAllText(Path.Combine(dir.Path, "a.txt"))); + Assert.Equal(FileOpResult.Ok, await FileOperations.SaveAsync(dir.Path, "a.txt", Stream("dos"), true, default)); + Assert.Equal("dos", File.ReadAllText(Path.Combine(dir.Path, "a.txt"))); + Assert.Equal(FileOpResult.InvalidName, await FileOperations.SaveAsync(dir.Path, "..", Stream("x"), true, default)); + Assert.Single(Directory.GetFiles(dir.Path)); + } + + [Fact] + public async Task PartsMustArriveInOrder() + { + using var dir = new TempDir(); + var r = await FileOperations.WritePartAsync(dir.Path, "big.bin", "abcdef12", 0, 6, Stream("abc"), false, default); + Assert.Equal((FileOpResult.Ok, false), r); + Assert.False(File.Exists(Path.Combine(dir.Path, "big.bin"))); + + r = await FileOperations.WritePartAsync(dir.Path, "big.bin", "abcdef12", 1, 6, Stream("zzz"), false, default); + Assert.Equal(FileOpResult.Conflict, r.Result); + + r = await FileOperations.WritePartAsync(dir.Path, "big.bin", "abcdef12", 3, 6, Stream("def"), false, default); + Assert.Equal((FileOpResult.Ok, true), r); + Assert.Equal("abcdef", File.ReadAllText(Path.Combine(dir.Path, "big.bin"))); + Assert.Single(Directory.GetFiles(dir.Path)); + } + + [Fact] + public void FoldersRenameAndDelete() + { + using var dir = new TempDir(); + Assert.Equal(FileOpResult.Ok, FileOperations.CreateFolder(dir.Path, "nueva")); + Assert.Equal(FileOpResult.Exists, FileOperations.CreateFolder(dir.Path, "nueva")); + Assert.Equal(FileOpResult.InvalidName, FileOperations.CreateFolder(dir.Path, ".oculta")); + dir.File("nueva/dentro.txt", "x"); + dir.File("otro.txt", "y"); + + Assert.Equal(FileOpResult.Exists, FileOperations.Rename(Path.Combine(dir.Path, "nueva"), "otro.txt")); + Assert.Equal(FileOpResult.Ok, FileOperations.Rename(Path.Combine(dir.Path, "nueva"), "vieja")); + Assert.True(File.Exists(Path.Combine(dir.Path, "vieja", "dentro.txt"))); + Assert.Equal(FileOpResult.Ok, FileOperations.Delete(Path.Combine(dir.Path, "vieja"))); + Assert.Equal(FileOpResult.NotFound, FileOperations.Delete(Path.Combine(dir.Path, "vieja"))); + } + + private static MemoryStream Stream(string text) => new(Encoding.UTF8.GetBytes(text)); +} + +public class ZipStreamWriterTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ProducesAValidZipOfTheAnnouncedLength(bool preferUtf8) + { + using var dir = new TempDir(); + dir.File("a.txt", "hola"); + dir.File("Año/b.txt", new string('x', 100_000)); + dir.File("日本/c.txt", "c"); + dir.Folder("vacia"); + dir.File(".oculto", "no"); + + var zip = new ZipStreamWriter(ZipStreamWriter.Collect(dir.Path), preferUtf8); + var ms = new MemoryStream(); + await zip.WriteAsync(ms, default); + Assert.Equal(zip.TotalLength, ms.Length); + + ms.Position = 0; + // Sin indicador UTF-8 los nombres van en CP437, como los leen los descompresores antiguos. + using var archive = new ZipArchive(ms, ZipArchiveMode.Read, false, Encodings.Cp437Strict); + var names = archive.Entries.Select(e => e.FullName).ToList(); + Assert.Contains("a.txt", names); + Assert.Contains("Año/b.txt", names); + Assert.Contains("日本/c.txt", names); + Assert.Contains("vacia/", names); + Assert.DoesNotContain(".oculto", names); + using var reader = new StreamReader(archive.GetEntry("Año/b.txt")!.Open()); + Assert.Equal(100_000, reader.ReadToEnd().Length); // CRC comprobado por ZipArchive al leer + } + + [Fact] + public void NameEncodingDependsOnTheClient() + { + Assert.False(ZipStreamWriter.EncodeName("Año.txt", preferUtf8: false).Utf8); + Assert.Equal(0xA4, ZipStreamWriter.EncodeName("ñ", preferUtf8: false).Name[0]); // ñ en CP437 + Assert.True(ZipStreamWriter.EncodeName("日本.txt", preferUtf8: false).Utf8); + Assert.True(ZipStreamWriter.EncodeName("Año.txt", preferUtf8: true).Utf8); + Assert.False(ZipStreamWriter.EncodeName("plain.txt", preferUtf8: true).Utf8); + } + + [Fact] + public void Crc32MatchesTheStandardCheckValue() => + Assert.Equal(0xCBF43926u, Crc32.Update(0, "123456789"u8)); +}