#!/bin/bash
# Set up a file server with a blog, open directory, upload page, and WebDAV access.
# Uses copyparty for file serving, a C# CMS for static blog generation,
# Let's Encrypt for TLS, and an HTTP-to-HTTPS redirect.
#
# Prerequisites:
#   - A public IP with ports 80 and 443 open
#   - A domain with an A/CNAME record pointing to that IP
#   - Root access on the server
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"

DEFAULT_DIR="$HOME/fileserver"
read -rp "Install path [${DEFAULT_DIR}]: " ROOT_DIR
ROOT_DIR="${ROOT_DIR:-$DEFAULT_DIR}"
ROOT_DIR="$(cd "$ROOT_DIR" 2>/dev/null && pwd || echo "$ROOT_DIR")"
mkdir -p "$ROOT_DIR"

CACHE_FILE="${ROOT_DIR}/.setup-cache"

CACHED_DOMAIN=""
CACHED_USERNAME=""
CACHED_PASSWORD=""
if [ -f "$CACHE_FILE" ]; then
    source "$CACHE_FILE"
    CACHED_DOMAIN="${DOMAIN:-}"
    CACHED_USERNAME="${USERNAME:-}"
    CACHED_PASSWORD="${PASSWORD:-}"
fi

echo "Before running this script, make sure you have:"
echo "  - A public IP with ports 80 and 443 open"
echo "  - A domain with an A/CNAME record pointing to that IP"
echo ""
read -rp "Domain [${CACHED_DOMAIN}]: " DOMAIN
DOMAIN="${DOMAIN:-$CACHED_DOMAIN}"

echo ""
echo "The username and password will create a copyparty account"
echo "with full read/write access at https://${DOMAIN}"
echo ""
read -rp "Username [${CACHED_USERNAME}]: " USERNAME
USERNAME="${USERNAME:-$CACHED_USERNAME}"
read -rsp "Password [${CACHED_PASSWORD:+****}]: " PASSWORD
echo
PASSWORD="${PASSWORD:-$CACHED_PASSWORD}"

cat > "$CACHE_FILE" << CACHEEOF
DOMAIN="$DOMAIN"
USERNAME="$USERNAME"
PASSWORD="$PASSWORD"
CACHEEOF
chmod 600 "$CACHE_FILE"
FILEROOT="${ROOT_DIR}/fileroot"
CERT_DIR="/etc/letsencrypt/live/${DOMAIN}"

COPYPARTY_CONF="[global]
	p: 443
	smb-port: 445
	smbw
	cert: /root/.config/copyparty/cert.pem
	shr: /share

[/]
	./fileroot/public
	flags:
		cachectl: no-store, max-age=0
		on403: ${ROOT_DIR}/on403.py
		on404: ${ROOT_DIR}/on404.py
	accs:
		h: *
		A: ${USERNAME}

[/opendir]
	./fileroot/public/opendir
	accs:
		r: *

[/upload]
	./fileroot/public/upload
	flags:
		sz: 0-1g
		maxn: 5,3600
		maxb: 1g,3600
		vmaxb: 10g
		nodupe
	accs:
		w: *
		A: ${USERNAME}

[accounts]
	${USERNAME}: ${PASSWORD}

[/private]
	./fileroot
	flags:
		on403: ${ROOT_DIR}/on403.py
	accs:
		A: ${USERNAME}"

COPYPARTY_SERVICE="[Unit]
Description=Copyparty file server
After=network.target

[Service]
Type=simple
WorkingDirectory=${ROOT_DIR}
ExecStart=/usr/bin/python3 ${ROOT_DIR}/copyparty-sfx.py -c ${ROOT_DIR}/copyparty.conf
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target"

CMS_SERVICE="[Unit]
Description=Blog CMS watcher
After=network.target

[Service]
Type=simple
ExecStart=/usr/bin/dotnet ${ROOT_DIR}/CMS.cs --mode watch --posts ${FILEROOT}/blog/posts --templates ${FILEROOT}/blog/templates --images ${FILEROOT}/blog/images --output ${FILEROOT}/public/blog --systempages ${FILEROOT}/systempages
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target"

HTTP_REDIRECT_PY="from http.server import HTTPServer, BaseHTTPRequestHandler

class RedirectHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        host = self.headers.get(\"Host\", \"${DOMAIN}\")
        self.send_response(301)
        self.send_header(\"Location\", f\"https://{host}{self.path}\")
        self.end_headers()

    do_POST = do_GET
    do_HEAD = do_GET

    def log_message(self, *args):
        pass

HTTPServer((\"\", 80), RedirectHandler).serve_forever()"

HTTP_REDIRECT_SERVICE="[Unit]
Description=HTTP to HTTPS redirect
After=network.target

[Service]
Type=simple
ExecStart=/usr/bin/python3 ${ROOT_DIR}/http-redirect.py
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target"

CERTBOT_DEPLOY_HOOK="#!/bin/bash
CERT_DIR=\"/etc/letsencrypt/live/${DOMAIN}\"
COPYPARTY_CERT=\"/root/.config/copyparty/cert.pem\"

cat \"\${CERT_DIR}/privkey.pem\" \"\${CERT_DIR}/fullchain.pem\" > \"\${COPYPARTY_CERT}\"
chmod 600 \"\${COPYPARTY_CERT}\"
systemctl restart copyparty"

CERTBOT_PRE_HOOK='#!/bin/bash
systemctl stop http-redirect'

CERTBOT_POST_HOOK='#!/bin/bash
systemctl start http-redirect'

PUBLIC_INDEX='<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>Welcome</title>
  <style>
    body { max-width:640px;margin:40px auto;padding:0 20px;font-family:Georgia,serif;line-height:1.7;background:#111;color:#ddd }
    a { color:#6cb4ee }
    a:visited { color:#a98cd6 }
    hr { border:none;border-top:1px solid #333;margin:1.5em 0 }
    ul { list-style:none;padding:0 }
    li { margin:0.5em 0 }
    li a { display:block;text-decoration:none;color:#ddd;padding:0.8em 1em;border-radius:6px;transition:background .15s }
    li a:hover { background:#1a1a1a }
    li a:visited { color:#ddd }
    .desc { color:#888;font-size:0.9em }
  </style>
</head>
<body>
  <h1>Welcome</h1>
  <hr>
  <ul>
    <li><a href="/blog/">Blog <span class="desc">— posts and articles</span></a></li>
    <li><a href="/opendir/">Open Directory <span class="desc">— public files, powered by copyparty</span></a></li>
    <li><a href="/upload/">Upload <span class="desc">— send me files</span></a></li>
  </ul>
</body>
</html>'

BLOG_IMAGES_INDEX='<!DOCTYPE html>
<html>
<head>
    <meta http-equiv="refresh" content="0; url=../">
</head>
</html>'

ON403_PY="import os

def main(cli, vn, rem):
    if cli.uname != \"*\":
        return \"\"

    not_found = os.path.join(os.path.dirname(os.path.abspath(__file__)), \"fileroot\", \"systempages\", \"notFound.html\")
    if not os.path.exists(not_found):
        return \"\"

    with open(not_found, \"rb\") as f:
        body = f.read()

    cli.reply(body, status=404, mime=\"text/html\")
    return \"true\""

ON404_PY="import os

def main(cli, vn, rem):
    if cli.uname != \"*\":
        return \"\"

    not_found = os.path.join(os.path.dirname(os.path.abspath(__file__)), \"fileroot\", \"systempages\", \"notFound.html\")
    if not os.path.exists(not_found):
        return \"\"

    with open(not_found, \"rb\") as f:
        body = f.read()

    cli.reply(body, status=404, mime=\"text/html\")
    return \"true\""

run() {
    local log
    log=$(mktemp)
    if ! "$@" >"$log" 2>&1; then
        echo "FAILED: $*" >&2
        cat "$log" >&2
        rm -f "$log"
        return 1
    fi
    rm -f "$log"
}

place() {
    local content="$1" dst="$2"
    if [ ! -f "$dst" ] || [ "$(cat "$dst")" != "$content" ]; then
        echo "$content" > "$dst"
    fi
}

do_setup() {
    echo "Running setup..."
    mkdir -p "${FILEROOT}/blog/posts" \
             "${FILEROOT}/blog/templates" \
             "${FILEROOT}/blog/images" \
             "${FILEROOT}/public/blog/images" \
             "${FILEROOT}/public/opendir" \
             "${FILEROOT}/public/upload" \
             "${FILEROOT}/public/adminlogin" \
             "${FILEROOT}/systempages"

    if [ ! -f "${FILEROOT}/public/index.html" ]; then
        echo "$PUBLIC_INDEX" > "${FILEROOT}/public/index.html"
    fi
    if [ ! -f "${FILEROOT}/public/blog/images/index.html" ]; then
        echo "$BLOG_IMAGES_INDEX" > "${FILEROOT}/public/blog/images/index.html"
    fi

    if [ ! -f "${FILEROOT}/public/adminlogin/index.html" ]; then
        cat > "${FILEROOT}/public/adminlogin/index.html" << 'LOGINEOF'
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>Login</title>
  <style>
    body { max-width:640px;margin:40px auto;padding:0 20px;font-family:Georgia,serif;line-height:1.7;background:#111;color:#ddd }
    hr { border:none;border-top:1px solid #333;margin:1.5em 0 }
    input { background:#1a1a1a;color:#ddd;border:1px solid #333;padding:0.5em;border-radius:4px;font-family:inherit;font-size:1em;width:100%;box-sizing:border-box }
    input:focus { outline:none;border-color:#6cb4ee }
    button { background:#222;color:#ddd;border:1px solid #333;padding:0.5em 1.5em;border-radius:4px;font-family:inherit;font-size:1em;cursor:pointer;transition:background .15s }
    button:hover { background:#1a1a1a }
    .field { margin:0.8em 0 }
    label { display:block;color:#888;font-size:0.9em;margin-bottom:0.3em }
    .error { color:#e55; display:none; margin-top:1em }
  </style>
</head>
<body>
  <h1>Login</h1>
  <hr>
  <form id="login">
    <div class="field">
      <label for="pw">Password</label>
      <input type="password" id="pw" name="pw" autofocus>
    </div>
    <button type="submit">Log in</button>
    <p class="error" id="error">Wrong password.</p>
  </form>
  <script>
    document.getElementById("login").addEventListener("submit", async function(e) {
      e.preventDefault();
      var pw = document.getElementById("pw").value;
      var r = await fetch("/private?pw=" + encodeURIComponent(pw));
      if (r.ok) {
        window.location.href = "/private";
      } else {
        document.getElementById("error").style.display = "block";
      }
    });
  </script>
</body>
</html>
LOGINEOF
    fi

    systemctl stop copyparty 2>/dev/null || true
    systemctl stop cms 2>/dev/null || true
    systemctl stop http-redirect 2>/dev/null || true

    if [ ! -f "${ROOT_DIR}/copyparty-sfx.py" ]; then
        run curl -fSL -o "${ROOT_DIR}/copyparty-sfx.py" \
            https://github.com/9001/copyparty/releases/latest/download/copyparty-sfx.py
    fi

    place "$COPYPARTY_CONF" "${ROOT_DIR}/copyparty.conf"
    place "$HTTP_REDIRECT_PY" "${ROOT_DIR}/http-redirect.py"
    place "$ON403_PY" "${ROOT_DIR}/on403.py"
    place "$ON404_PY" "${ROOT_DIR}/on404.py"

    if [ ! -f "${ROOT_DIR}/CMS.cs" ]; then
        cat > "${ROOT_DIR}/CMS.cs" << 'CMSEOF'
#:package Markdig@1.1.1
#:package ColorCode.HTML@2.0.15

// At the time of writing, AOT is not supported on all platforms.
// Will be fixed in dotnet 10.0.4, at which point this line can be removed:
#:property PublishAot=false

using System.Diagnostics;
using System.Net;
using System.Text;
using ColorCode;
using Markdig;
using Markdig.Renderers;
using Markdig.Syntax;

string? mode = null;
var root = Directory.GetCurrentDirectory();
var postsDir = Path.Combine(root, "posts");
var templateDir = Path.Combine(root, "templates");
var imagesDir = Path.Combine(root, "images");
var outDir = Path.Combine(root, "../public/blog/");
var systemPagesDir = Path.Combine(root, "../systempages/");

for (var argIndex = 0; argIndex < args.Length; argIndex++)
{
    switch (args[argIndex])
    {
        case "--mode": mode = args[++argIndex]; break;
        case "--posts": postsDir = args[++argIndex]; break;
        case "--templates": templateDir = args[++argIndex]; break;
        case "--images": imagesDir = args[++argIndex]; break;
        case "--output": outDir = args[++argIndex]; break;
        case "--systempages": systemPagesDir = args[++argIndex]; break;
        default:
            Console.Error.WriteLine($"Unknown argument: {args[argIndex]}");
            return 1;
    }
}

if (mode == null)
{
    Console.Write("Generate once or watch for changes? [once/watch]: ");
    mode = Console.ReadLine()?.Trim().ToLowerInvariant();
}

var outImages = Path.Combine(outDir, "images");

var markdownPipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
var codeFormatter = new HtmlClassFormatter();

if (mode != "once" && mode != "watch")
{
    Console.Error.WriteLine($"Unknown mode: {mode}");
    return 1;
}

return mode == "watch" ? Watch() : Generate();

int Watch()
{
    Generate();

    var lastRun = DateTime.MinValue;

    void OnChange(object sender, FileSystemEventArgs eventArgs)
    {
        var fileName = Path.GetFileName(eventArgs.Name);
        if (fileName == null) return;
        if (fileName.StartsWith(".") || fileName.Contains(".tmp")) return;

        var now = DateTime.Now;
        if ((now - lastRun).TotalMilliseconds < 500) return;
        lastRun = now;

        Console.WriteLine($"[{now:HH:mm:ss}] {eventArgs.ChangeType}: {eventArgs.Name}");
        Generate();
    }

    FileSystemWatcher CreateWatcher(string path)
    {
        var watcher = new FileSystemWatcher(path);
        watcher.NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.Size;
        watcher.Created += OnChange;
        watcher.Changed += OnChange;
        watcher.Deleted += OnChange;
        watcher.Renamed += OnChange;
        watcher.EnableRaisingEvents = true;
        return watcher;
    }

    using var postsWatcher = CreateWatcher(postsDir);
    using var templatesWatcher = CreateWatcher(templateDir);
    using var imagesWatcher = CreateWatcher(imagesDir);

    Console.WriteLine("Watching for changes... (Ctrl+C to stop)");

    var exit = new ManualResetEventSlim();
    Console.CancelKeyPress += (sender, eventArgs) => { eventArgs.Cancel = true; exit.Set(); };
    exit.Wait();

    Console.WriteLine("\nStopped.");
    return 0;
}

int Generate()
{
    foreach (var dir in new[] { postsDir, imagesDir, templateDir, outDir, outImages, systemPagesDir })
    {
        if (!Directory.Exists(dir))
        {
            Directory.CreateDirectory(dir);
            Console.WriteLine($"Created {dir}/");
        }
    }

    var templateErrors = new List<string>();

    string LoadTemplate(string name, string defaultContent, params string[] requiredPlaceholders)
    {
        var path = Path.Combine(templateDir, name);
        if (!File.Exists(path))
        {
            File.WriteAllText(path, defaultContent);
            Console.WriteLine($"Created template: {Path.Combine(templateDir, name)}");
            return defaultContent;
        }
        var content = File.ReadAllText(path);
        foreach (var placeholder in requiredPlaceholders)
        {
            if (!content.Contains(placeholder))
                templateErrors.Add($"{Path.Combine(templateDir, name)}: missing placeholder {placeholder}");
        }
        return content;
    }

    var indexTemplate = LoadTemplate("index.html", @"<!DOCTYPE html>
<html lang=""en"">
<head>
  <meta charset=""utf-8"">
  <meta name=""viewport"" content=""width=device-width,initial-scale=1"">
  <title>Blog</title>
  <style>
    body { max-width:640px;margin:40px auto;padding:0 20px;font-family:Georgia,serif;line-height:1.7;background:#111;color:#ddd }
    hr { border:none;border-top:1px solid #333;margin:1.5em 0 }
    .entry { display:block;text-decoration:none;color:inherit;padding:1em;margin:0 -1em 1em;border-radius:6px;transition:background .15s }
    .entry:hover { background:#1a1a1a }
    .date { color:#888;font-size:0.9em;margin-top:0.2em }
    .entry-title { font-size:1.2em;font-weight:bold;color:#ddd }
    .entry-preview { margin-top:0.5em;color:#999 }
  </style>
</head>
<body>
  <h1>Blog</h1>
  <hr>
{{POSTS}}
</body>
</html>", "{{POSTS}}");

    var postTemplate = LoadTemplate("post.html", @"<!DOCTYPE html>
<html lang=""en"">
<head>
  <meta charset=""utf-8"">
  <meta name=""viewport"" content=""width=device-width,initial-scale=1"">
  <title>{{TITLE}}</title>
  <style>
    body { max-width:640px;margin:40px auto;padding:0 20px;font-family:Georgia,serif;line-height:1.7;background:#111;color:#ddd }
    a { color:#6cb4ee }
    a:visited { color:#a98cd6 }
    hr { border:none;border-top:1px solid #333;margin:1.5em 0 }
    .back, .back:visited { text-decoration:none;color:#aaa;padding:0.5em 0.8em;border-radius:6px;margin-left:-0.8em;transition:background .15s }
    .back:hover { background:#1a1a1a }
    .date { color:#888 }
    img { max-width:100%;height:auto;display:block;margin:1.5em 0;border-radius:4px }
    pre { background:#1a1a1a;padding:1em;border-radius:6px;overflow-x:auto;font-size:0.9em;line-height:1.5 }
    code { font-family:Consolas,Monaco,monospace }
    .keyword { color:#c586c0 }
    .string { color:#ce9178 }
    .comment { color:#6a9955 }
    .number { color:#b5cea8 }
    .type { color:#4ec9b0 }
    .attribute { color:#9cdcfe }
    .htmlTagDelimiter { color:#808080 }
    .htmlElementName { color:#569cd6 }
    .htmlAttributeName { color:#9cdcfe }
    .htmlAttributeValue { color:#ce9178 }
    .htmlOperator { color:#808080 }
    .post-nav { display:flex;justify-content:space-between;margin-top:3em;padding-top:1.5em;border-top:1px solid #333;font-size:0.9em }
    .post-nav a, .post-nav span { max-width:45% }
    .post-nav a, .post-nav a:visited { text-decoration:none;color:#aaa;padding:0.5em 0.8em;border-radius:6px;transition:background .15s }
    .post-nav a:hover { background:#1a1a1a }
    .post-nav > :first-child { text-align:left }
    .post-nav > :last-child { text-align:right;margin-left:auto }
  </style>
</head>
<body>
  <a href=""/"" class=""back"">&larr; back</a>
  <h1>{{TITLE}}</h1>
  <time class=""date"">{{DATE}}</time>
  <hr>
  {{BODY}}
  <nav class=""post-nav"">
    <a href=""{{PREV_SLUG}}.html"" {{PREV_HIDDEN}}>&larr; {{PREV_TITLE}}</a>
    <a href=""{{NEXT_SLUG}}.html"" {{NEXT_HIDDEN}}>{{NEXT_TITLE}} &rarr;</a>
  </nav>
</body>
</html>",
        "{{TITLE}}", "{{DATE}}", "{{BODY}}", "{{PREV_SLUG}}", "{{PREV_TITLE}}",
        "{{PREV_HIDDEN}}", "{{NEXT_SLUG}}", "{{NEXT_TITLE}}", "{{NEXT_HIDDEN}}"
    );

    var entryTemplate = LoadTemplate("entry.html", @"  <a href=""{{SLUG}}.html"" class=""entry"">
    <article>
      <div class=""entry-title"">{{TITLE}}</div>
      <div class=""date"">{{DATE}}</div>
      <p class=""entry-preview"">{{PREVIEW}}</p>
    </article>
  </a>", "{{SLUG}}", "{{TITLE}}", "{{DATE}}", "{{PREVIEW}}");

    if (templateErrors.Count > 0)
    {
        foreach (var err in templateErrors)
            Console.Error.WriteLine(err);
        return 1;
    }

    if (Directory.GetFiles(postsDir, "*.txt").Length == 0)
    {
        var today = DateTime.Now.ToString("yyyy-MM-dd");
        File.WriteAllText(Path.Combine(postsDir, "hello-world.txt"),
            $"Hello World | {today}\n\nThis is your first post. Edit it or add more `.txt` files to `{postsDir}/`.\n");
        Console.WriteLine($"Created starter post: {Path.Combine(postsDir, "hello-world.txt")}");
    }

    var posts = new List<(string Title, string Date, string Slug, string BodyHtml, string Preview)>();
    foreach (var file in Directory.GetFiles(postsDir, "*.txt"))
    {
        var lines = File.ReadAllLines(file);
        var fileName = Path.GetFileName(file);

        if (lines.Length == 0)
        {
            Console.Error.WriteLine($"{Path.Combine(postsDir, fileName)}: empty file");
            return 1;
        }

        var header = lines[0].Split('|');
        if (header.Length != 2)
        {
            Console.Error.WriteLine($"{Path.Combine(postsDir, fileName)}: first line must be title|date");
            return 1;
        }

        var title = WebUtility.HtmlEncode(header[0].Trim());
        var date = WebUtility.HtmlEncode(header[1].Trim());

        if (title.Length == 0 || date.Length == 0)
        {
            Console.Error.WriteLine($"{Path.Combine(postsDir, fileName)}: title and date cannot be empty");
            return 1;
        }

        var bodyLines = lines.Skip(1).ToList();
        var body = string.Join("\n", bodyLines);
        var markdown = HighlightCodeBlocks(Markdown.Parse(body, markdownPipeline), body, markdownPipeline);
        var slug = Slug(title);
        var preview = WebUtility.HtmlEncode(FirstParagraph(bodyLines));

        posts.Add((title, date, slug, markdown, preview));
    }

    // Newest first
    posts.Sort((left, right) => string.Compare(right.Date, left.Date, StringComparison.Ordinal));

    for (int postIndex = 0; postIndex < posts.Count; postIndex++)
    {
        var post = posts[postIndex];

        var hasPrev = postIndex < posts.Count - 1;
        var hasNext = postIndex > 0;

        var html = postTemplate
            .Replace("{{TITLE}}", post.Title)
            .Replace("{{DATE}}", post.Date)
            .Replace("{{BODY}}", post.BodyHtml)
            .Replace("{{PREV_SLUG}}", hasPrev ? posts[postIndex + 1].Slug : "")
            .Replace("{{PREV_TITLE}}", hasPrev ? posts[postIndex + 1].Title : "")
            .Replace("{{PREV_HIDDEN}}", hasPrev ? "" : "hidden")
            .Replace("{{NEXT_SLUG}}", hasNext ? posts[postIndex - 1].Slug : "")
            .Replace("{{NEXT_TITLE}}", hasNext ? posts[postIndex - 1].Title : "")
            .Replace("{{NEXT_HIDDEN}}", hasNext ? "" : "hidden");

        var postPath = Path.Combine(outDir, post.Slug + ".html");
        File.WriteAllText(postPath, html);
    }

    var previews = new StringBuilder();
    foreach (var post in posts)
    {
        var preview = entryTemplate
            .Replace("{{SLUG}}", post.Slug)
            .Replace("{{TITLE}}", post.Title)
            .Replace("{{DATE}}", post.Date)
            .Replace("{{PREVIEW}}", post.Preview);
        previews.Append(preview);
    }
    var indexPath = Path.Combine(outDir, "index.html");
    File.WriteAllText(indexPath, indexTemplate.Replace("{{POSTS}}", previews.ToString()));

    var notFoundPath = Path.Combine(systemPagesDir, "notFound.html");
    if (!File.Exists(notFoundPath))
    {
        File.WriteAllText(notFoundPath, @"<!DOCTYPE html>
<html lang=""en"">
<head>
  <meta charset=""utf-8"">
  <meta name=""viewport"" content=""width=device-width,initial-scale=1"">
  <title>Not Found</title>
  <style>
    body { max-width:640px;margin:40px auto;padding:0 20px;font-family:Georgia,serif;line-height:1.7;background:#111;color:#ddd }
    a { color:#6cb4ee }
    a:visited { color:#a98cd6 }
    hr { border:none;border-top:1px solid #333;margin:1.5em 0 }
  </style>
</head>
<body>
  <h1>Not Found</h1>
  <hr>
  <p>The page you're looking for doesn't exist.</p>
  <p><a href=""/"">Go home</a></p>
</body>
</html>");
        Console.WriteLine($"Created: {Path.GetFullPath(notFoundPath)}");
    }

    var generatedFiles = new HashSet<string>(StringComparer.Ordinal);
    generatedFiles.Add(Path.GetFullPath(indexPath));
    foreach (var post in posts)
        generatedFiles.Add(Path.GetFullPath(Path.Combine(outDir, post.Slug + ".html")));

    var imagesIndexPath = Path.Combine(outImages, "index.html");
    File.WriteAllText(imagesIndexPath, @"<!DOCTYPE html>
<html>
<head>
    <meta http-equiv=""refresh"" content=""0; url=../"">
</head>
</html>");
    generatedFiles.Add(Path.GetFullPath(imagesIndexPath));

    foreach (var src in Directory.GetFiles(imagesDir))
    {
        var dest = Path.Combine(outImages, Path.GetFileName(src));
        File.Copy(src, dest, overwrite: true);
        generatedFiles.Add(Path.GetFullPath(dest));
    }

    foreach (var file in Directory.GetFiles(outDir))
    {
        if (Path.GetFileName(file) == "index.html") continue;
        if (!generatedFiles.Contains(Path.GetFullPath(file)))
        {
            File.Delete(file);
            Console.WriteLine($"Removed: {Path.GetFileName(file)}");
        }
    }
    foreach (var file in Directory.GetFiles(outImages))
    {
        if (Path.GetFileName(file) == "index.html") continue;
        if (!generatedFiles.Contains(Path.GetFullPath(file)))
        {
            File.Delete(file);
            Console.WriteLine($"Removed: images/{Path.GetFileName(file)}");
        }
    }

    Console.WriteLine($"Generated {posts.Count} post(s) -> {outDir}/");
    return 0;
}

string FirstParagraph(List<string> lines)
{
    var parts = new List<string>();
    foreach (var line in lines)
    {
        if (string.IsNullOrWhiteSpace(line))
            if(parts.Count > 0)
                break;
            else
                continue;

        if (line.Contains("!["))
            break;

        parts.Add(line);
    }
    return string.Join(" ", parts);
}

string Slug(string title)
{
    var sb = new StringBuilder();
    foreach (var ch in title.ToLowerInvariant())
    {
        if (char.IsLetterOrDigit(ch))
            sb.Append(ch);

        else if (ch == ' ' && sb.Length > 0 && sb[^1] != '-')
            sb.Append('-');
    }
    return sb.ToString().TrimEnd('-');
}

string HighlightCodeBlocks(MarkdownDocument document, string source, MarkdownPipeline pipeline)
{
    var html = document.ToHtml(pipeline);

    foreach (var block in document.Descendants<FencedCodeBlock>())
    {
        var langTag = block.Info;
        if (string.IsNullOrEmpty(langTag))
            continue;

        var language = Languages.FindById(langTag);
        if (language == null)
            continue;

        var code = new StringBuilder();
        foreach (var line in block.Lines)
            code.AppendLine(line.ToString());
        var codeText = code.ToString().TrimEnd();

        var blockWriter = new StringWriter();
        var blockRenderer = new HtmlRenderer(blockWriter);
        pipeline.Setup(blockRenderer);
        blockRenderer.Render(block);
        var blockHtml = blockWriter.ToString();
        var highlighted = codeFormatter.GetHtmlString(codeText, language);

        html = html.Replace(blockHtml, highlighted);
    }

    return html;
}
CMSEOF
    fi

    place "$COPYPARTY_SERVICE" /etc/systemd/system/copyparty.service
    place "$CMS_SERVICE" /etc/systemd/system/cms.service
    place "$HTTP_REDIRECT_SERVICE" /etc/systemd/system/http-redirect.service
    run systemctl daemon-reload
    run systemctl enable copyparty cms http-redirect

    if ! command -v dotnet &>/dev/null; then
        run curl -fSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh
        run bash /tmp/dotnet-install.sh --channel 10.0
        rm -f /tmp/dotnet-install.sh
        ln -sf /root/.dotnet/dotnet /usr/local/bin/dotnet
    fi

    if [ ! -d "${CERT_DIR}" ]; then
        run apt-get update -qq
        run apt-get install -y certbot
        run certbot certonly \
            --standalone \
            --preferred-challenges http \
            --non-interactive \
            --agree-tos \
            --register-unsafely-without-email \
            --pre-hook "systemctl stop http-redirect" \
            --post-hook "systemctl start http-redirect" \
            -d "${DOMAIN}"
        mkdir -p /root/.config/copyparty
        cat "${CERT_DIR}/privkey.pem" "${CERT_DIR}/fullchain.pem" > /root/.config/copyparty/cert.pem
        chmod 600 /root/.config/copyparty/cert.pem
    fi

    mkdir -p /etc/letsencrypt/renewal-hooks/deploy \
             /etc/letsencrypt/renewal-hooks/pre \
             /etc/letsencrypt/renewal-hooks/post
    place "$CERTBOT_DEPLOY_HOOK" /etc/letsencrypt/renewal-hooks/deploy/copyparty.sh
    place "$CERTBOT_PRE_HOOK" /etc/letsencrypt/renewal-hooks/pre/http-redirect.sh
    place "$CERTBOT_POST_HOOK" /etc/letsencrypt/renewal-hooks/post/http-redirect.sh
    chmod +x /etc/letsencrypt/renewal-hooks/deploy/copyparty.sh \
             /etc/letsencrypt/renewal-hooks/pre/http-redirect.sh \
             /etc/letsencrypt/renewal-hooks/post/http-redirect.sh

    run systemctl start copyparty
    run systemctl start cms
    run systemctl start http-redirect

    run certbot renew --dry-run --no-random-sleep-on-renew

    echo ""
    echo "Setup complete."
    echo "  Visit https://${DOMAIN} to view the server."
    echo "  Visit https://${DOMAIN}/blog/ to view the blog."
    echo "  Log in at https://${DOMAIN}/adminlogin to manage your files."
}

do_mount_script() {
    cat << 'EOF'
#!/usr/bin/env bash
set -euo pipefail
EOF
    cat << EOF

REMOTE_NAME="fileserver-dav"
WEBDAV_URL="https://${DOMAIN}"
WEBDAV_USER="${USERNAME}"
WEBDAV_PASS="${PASSWORD}"
MOUNT_PATH="\$HOME/mnt/${DOMAIN}"
EOF
    cat << 'EOF'

SERVICE_NAME="rclone-${REMOTE_NAME}.service"
SERVICE_FILE="$HOME/.config/systemd/user/$SERVICE_NAME"

enable_mount() {
    rclone config create "$REMOTE_NAME" webdav \
        url="$WEBDAV_URL" vendor=owncloud pacer_min_sleep=0.01ms \
        user="$WEBDAV_USER" pass="$(rclone obscure "$WEBDAV_PASS")"

    mkdir -p "$(dirname "$SERVICE_FILE")"
    cat > "$SERVICE_FILE" << SVCEOF
[Unit]
Description=rclone mount ${REMOTE_NAME}
After=network-online.target
Wants=network-online.target

[Service]
Type=notify
ExecStartPre=/usr/bin/mkdir -p ${MOUNT_PATH}
ExecStart=/usr/bin/rclone mount --vfs-cache-mode writes --dir-cache-time 5s ${REMOTE_NAME}: ${MOUNT_PATH}
ExecStop=/bin/fusermount -u ${MOUNT_PATH}
Restart=on-failure

[Install]
WantedBy=default.target
SVCEOF

    systemctl --user daemon-reload
    systemctl --user enable --now "$SERVICE_NAME"
    loginctl enable-linger "$USER"
    echo "Enabled. Mount at: ${MOUNT_PATH}"
}

disable_mount() {
    systemctl --user disable --now "$SERVICE_NAME" 2>/dev/null || true
    rm -f "$SERVICE_FILE"
    systemctl --user daemon-reload
    fusermount -u "$MOUNT_PATH" 2>/dev/null || true
    echo "Disabled."
}

echo "rclone WebDAV mount: ${WEBDAV_URL}"
echo "  1) Enable   2) Disable"
read -rp "Choice [1/2]: " choice
case "$choice" in
    1) enable_mount ;;
    2) disable_mount ;;
    *) echo "Invalid choice"; exit 1 ;;
esac
EOF

    echo ""
    echo "Copy and run the script above on your computer to mount the server as a drive."
}

while true; do
    echo ""
    echo "What would you like to do?"
    echo "  1) Setup server"
    echo "  2) Get drive mount script"
    echo "  3) Exit"
    read -rp "> " choice
    case "$choice" in
        1) do_setup ;;
        2) do_mount_script ;;
        3) break ;;
        *) echo "Invalid choice." ;;
    esac
done
