using System.Globalization;
using Sunfish.Shares;
namespace Sunfish.Web;
/// <summary>Una fila del navegador de directorios.</summary>
internal sealed record ListingItem(FileEntry Entry, bool HasIndex, string? Alias = null, bool MacFork = false);
/// <summary>Lo que se puede hacer en la carpeta mostrada, con el token anti-CSRF de cada accion.</summary>
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<string, string> Token { get; init; } = _ => "";
public bool Any => Upload || Rename || Delete || Zip;
}
/// <summary>
/// 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).
/// </summary>
internal static class DirectoryPage
{
internal const string Font = "<FONT FACE=\"Verdana,Arial,Helvetica\">";
internal const string EndFont = "</FONT>";
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<string> Segments { get; init; } = [];
public IReadOnlyList<ListingItem> 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;
/// <summary>URL de la carpeta, codificada y con barra final.</summary>
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("<script src=\"/$sunfish/res/sunfish.js\"></script>\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("<H2>").Raw(Font).Raw(status.ToString(CultureInfo.InvariantCulture)).Raw(" ").Text(Strings.ErrorTitle(status)).Raw(EndFont).Raw("</H2>\n");
string text = detail ?? Strings.ErrorText(status);
if (text.Length > 0)
html.Raw("<P>").Raw(Font).Text(text).Raw(EndFont).Raw("</P>\n");
html.Raw("<P>").Raw(Font).Raw("<A HREF=\"/").Raw(ProfileQuery(profile, true)).Raw("\">/</A>").Raw(EndFont).Raw("</P>\n");
Footer(html);
return html;
}
/// <summary>Pagina para escribir el nombre nuevo (sin JavaScript).</summary>
public static HtmlBuilder RenderRename(Model m, string name)
{
var html = new HtmlBuilder(classic: !m.Profile.IsModern);
Header(html, m.ServerName + " - " + Strings.Rename);
html.Raw("<H2>").Raw(Font).Text(Strings.RenameTitle(name)).Raw(EndFont).Raw("</H2>\n");
FormStart(html, m, "rename", multipart: false);
html.Raw("<INPUT TYPE=HIDDEN NAME=\"name\" VALUE=\"").Text(name).Raw("\">")
.Raw(Font).Text(Strings.NewName).Raw(EndFont).Raw(" <INPUT TYPE=TEXT NAME=\"to\" SIZE=40 VALUE=\"").Text(name).Raw("\"> ")
.Raw("<INPUT TYPE=SUBMIT VALUE=\"").Text(Strings.Rename).Raw("\"></FORM>\n");
CancelLink(html, m);
Footer(html);
return html;
}
/// <summary>Confirmacion de borrado (sin JavaScript).</summary>
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("<H2>").Raw(Font).Text(Strings.Delete).Raw(EndFont).Raw("</H2>\n<P>").Raw(Font)
.Text(isDirectory ? Strings.DeleteFolderQuestion(name) : Strings.DeleteFileQuestion(name)).Raw(EndFont).Raw("</P>\n");
FormStart(html, m, "delete", multipart: false);
html.Raw("<INPUT TYPE=HIDDEN NAME=\"name\" VALUE=\"").Text(name).Raw("\">")
.Raw("<INPUT TYPE=SUBMIT VALUE=\"").Text(Strings.Delete).Raw("\"></FORM>\n");
CancelLink(html, m);
Footer(html);
return html;
}
private static void CancelLink(HtmlBuilder html, Model m) =>
html.Raw("<P>").Raw(Font).Raw("<A HREF=\"").Raw(m.FolderUrl).Raw(ProfileQuery(m.Profile, true)).Raw("\">")
.Text(Strings.Cancel).Raw("</A>").Raw(EndFont).Raw("</P>\n");
internal static void Header(HtmlBuilder html, string title)
{
html.Raw("<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 3.2 Final//EN\">\n<HTML><HEAD><TITLE>")
.Text(title).Raw("</TITLE>\n")
.Raw("<META HTTP-EQUIV=\"Content-Type\" CONTENT=\"").Raw(html.ContentType).Raw("\">\n");
if (!html.Classic)
html.Raw("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<style>")
.Raw(ModernStyle).Raw("</style>\n");
html.Raw("</HEAD>\n<BODY BGCOLOR=\"#FFFFFF\" TEXT=\"#000000\" LINK=\"#1A4F8A\" VLINK=\"#5A3D8A\">\n");
}
internal static void Footer(HtmlBuilder html) =>
html.Raw("<HR>\n").Raw(Font).Raw("<FONT SIZE=\"-1\">Sunfish 3.0</FONT>").Raw(EndFont).Raw("\n</BODY></HTML>\n");
private static void RenderTable(HtmlBuilder html, Model m)
{
string q = ProfileQuery(m.Profile, first: true);
html.Raw("<TABLE BORDER=0 CELLPADDING=2 CELLSPACING=0 WIDTH=\"100%\"><TR>")
.Raw("<TD WIDTH=52 VALIGN=TOP><A HREF=\"/").Raw(q).Raw("\"><IMG SRC=\"/$sunfish/res/logo.gif\" WIDTH=48 HEIGHT=48 BORDER=0 ALT=\"Sunfish\"></A></TD>")
.Raw("<TD VALIGN=MIDDLE>").Raw(Font).Raw("<FONT SIZE=\"+2\"><B>").Text(m.ServerName).Raw("</B></FONT><BR>");
Breadcrumb(html, m, q);
html.Raw(EndFont).Raw("</TD></TR></TABLE>\n<HR>\n");
bool rowActions = m.Actions.Rename || m.Actions.Delete;
html.Raw("<TABLE CLASS=\"list\" BORDER=0 CELLPADDING=3 CELLSPACING=0 WIDTH=\"100%\">\n")
.Raw("<TR BGCOLOR=\"#DDE8F4\">");
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("<TH></TH>");
html.Raw("</TR>\n");
string emptyActions = rowActions ? "<TD></TD>" : "";
if (!m.IsRoot)
{
html.Raw("<TR><TD>").Raw(Font).Raw("<A HREF=\"../").Raw(q).Raw("\"><IMG SRC=\"/$sunfish/res/up.gif\" WIDTH=16 HEIGHT=16 BORDER=0 ALT=\"[..]\"> ..</A>")
.Raw(EndFont).Raw("</TD><TD></TD><TD CLASS=\"date\"></TD>").Raw(emptyActions).Raw("</TR>\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("<TR").Raw(bg).Raw("><TD>").Raw(Font)
.Raw("<A HREF=\"").Raw(href).Raw(q).Raw("\">")
.Raw(e.IsDirectory
? "<IMG SRC=\"/$sunfish/res/folder.gif\" WIDTH=16 HEIGHT=16 BORDER=0 ALT=\"[DIR]\"> "
: "<IMG SRC=\"/$sunfish/res/file.gif\" WIDTH=16 HEIGHT=16 BORDER=0 ALT=\"[ ]\"> ")
.Text(shown).Raw(e.IsDirectory ? "/" : "").Raw("</A>");
Extras(html, m, item, href);
if (item.HasIndex)
html.Raw(" <FONT SIZE=\"-1\" CLASS=\"extra\">[<A HREF=\"").Raw(href).Raw("?$list").Raw(ProfileQuery(m.Profile, first: false))
.Raw("\">").Text(Strings.FolderContents).Raw("</A>]</FONT>");
html.Raw(EndFont).Raw("</TD><TD ALIGN=RIGHT NOWRAP>").Raw(Font)
.Text(e.IsDirectory ? "" : FormatSize(e.Size)).Raw(EndFont)
.Raw("</TD><TD NOWRAP CLASS=\"date\">").Raw(Font)
.Text(e.IsVirtual ? "" : FormatDate(e.Modified)).Raw(EndFont).Raw("</TD>");
if (rowActions)
{
html.Raw("<TD NOWRAP>").Raw(Font).Raw("<FONT SIZE=\"-1\" CLASS=\"extra\">");
if (!e.IsVirtual)
RowActionLinks(html, m, e.Name);
html.Raw("</FONT>").Raw(EndFont).Raw("</TD>");
}
html.Raw("</TR>\n");
}
if (m.Items.Count == 0)
html.Raw("<TR><TD COLSPAN=3>").Raw(Font).Raw("<I>").Text(Strings.EmptyFolder).Raw("</I>").Raw(EndFont).Raw("</TD>").Raw(emptyActions).Raw("</TR>\n");
html.Raw("</TABLE>\n");
}
private static void RenderText(HtmlBuilder html, Model m)
{
string q = ProfileQuery(m.Profile, first: true);
html.Raw("<H1>").Text(m.ServerName).Raw("</H1>\n<P>");
Breadcrumb(html, m, q);
html.Raw("</P>\n<UL>\n");
if (!m.IsRoot)
html.Raw("<LI><A HREF=\"../").Raw(q).Raw("\">..</A> (").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("<LI><A HREF=\"").Raw(href).Raw(q).Raw("\">").Text(shown).Raw(e.IsDirectory ? "/" : "").Raw("</A>");
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("<LI>").Text(Strings.EmptyFolder).Raw("\n");
html.Raw("</UL>\n");
}
/// <summary>
/// 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).
/// </summary>
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(" <FONT SIZE=\"-1\" CLASS=\"extra\">(").Text(item.Entry.Name).Raw(")</FONT>");
if (item.MacFork && m.Profile.Kind != ClientKind.Mac)
{
string pq = ProfileQuery(m.Profile, first: false);
html.Raw(" <FONT SIZE=\"-1\" CLASS=\"extra\">[<A HREF=\"").Raw(href).Raw("?as=macbin").Raw(pq).Raw("\">.bin</A>]")
.Raw(" [<A HREF=\"").Raw(href).Raw("?as=hqx").Raw(pq).Raw("\">.hqx</A>]</FONT>");
}
}
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(" [<A HREF=\"?$rename=").Raw(encoded).Raw(pq).Raw("\">").Text(Strings.RenameShort).Raw("</A>]");
if (m.Actions.Delete)
html.Raw(" [<A HREF=\"?$delete=").Raw(encoded).Raw(pq).Raw("\">").Text(Strings.DeleteShort).Raw("</A>]");
}
/// <summary>
/// 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.
/// </summary>
private static void RenderForms(HtmlBuilder html, Model m)
{
var a = m.Actions;
if (!a.Any)
return;
html.Raw("<HR>\n");
if (a.Upload)
{
FormStart(html, m, "upload", multipart: true);
html.Raw(Font).Text(Strings.UploadHint).Raw(":").Raw(EndFont).Raw("<BR>")
.Raw("<INPUT TYPE=FILE NAME=\"f\" MULTIPLE> <INPUT TYPE=SUBMIT VALUE=\"").Text(Strings.Upload).Raw("\"> ")
.Raw("<SPAN ID=\"sf-status\"></SPAN></FORM>\n");
FormStart(html, m, "mkdir", multipart: false);
html.Raw(Font).Text(Strings.NewFolder).Raw(":").Raw(EndFont)
.Raw(" <INPUT TYPE=TEXT NAME=\"name\" SIZE=24> <INPUT TYPE=SUBMIT VALUE=\"").Text(Strings.Create).Raw("\"></FORM>\n");
}
if (a.Zip)
{
html.Raw("<P>").Raw(Font).Raw("<A HREF=\"?$zip").Raw(ProfileQuery(m.Profile, first: false)).Raw("\">[ZIP] ")
.Text(Strings.DownloadZip).Raw("</A>").Raw(EndFont).Raw("</P>\n");
}
}
private static void FormStart(HtmlBuilder html, Model m, string action, bool multipart)
{
html.Raw("<FORM METHOD=POST ACTION=\"").Raw(m.FolderUrl).Raw(ProfileQuery(m.Profile, true)).Raw("\"")
.Raw(multipart ? " ENCTYPE=\"multipart/form-data\"" : "");
if (action == "upload")
html.Raw(" ID=\"sf-upload\" DATA-UPLOADING=\"").Text(Strings.Uploading).Raw("\" DATA-DONE=\"").Text(Strings.UploadDone).Raw("\"");
html.Raw(">")
.Raw("<INPUT TYPE=HIDDEN NAME=\"a\" VALUE=\"").Raw(action).Raw("\">")
.Raw("<INPUT TYPE=HIDDEN NAME=\"t\" VALUE=\"").Raw(m.Actions.Token(action)).Raw("\">");
}
private static void Breadcrumb(HtmlBuilder html, Model m, string q)
{
html.Raw("<A HREF=\"/").Raw(q).Raw("\">/</A>");
string link = "/";
foreach (var segment in m.Segments)
{
link += HtmlBuilder.UrlSegment(segment) + "/";
html.Raw(" <A HREF=\"").Raw(link).Raw(q).Raw("\">").Text(segment).Raw("</A> /");
}
}
private static void HeaderCell(HtmlBuilder html, string align, string sort, string label, Model m, string extra)
{
html.Raw("<TH ALIGN=").Raw(align).Raw(extra).Raw(">").Raw(Font)
.Raw("<A HREF=\"?sort=").Raw(sort).Raw(ProfileQuery(m.Profile, first: false)).Raw("\">")
.Text(label).Raw("</A>").Raw(EndFont).Raw("</TH>");
}
/// <summary>"?p=dos" si el perfil se forzo (los clientes sin cookies lo pierden si no).</summary>
internal static string ProfileQuery(ClientProfile profile, bool first) =>
profile.Forced ? (first ? "?p=" : "&p=") + profile.Code : "";
internal static string UrlOf(IReadOnlyList<string> segments) =>
"/" + string.Concat(segments.Select(s => HtmlBuilder.UrlSegment(s) + "/"));
internal static IEnumerable<ListingItem> Sorted(IEnumerable<ListingItem> 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);
}