// Plus+ Deno app
// Simple image/video posting system with uploads, thumbnails and login.
// Run with: deno run --allow-net --allow-read --allow-write --allow-run plus_deno_port.ts

import { serve } from "std/http";
import { extname, basename, join } from "std/path";
import { createHash } from "node:crypto";

// ----- Config -----
// Basic paths and server settings.
// Paths and server setup.
const ROOT = `${Deno.cwd()}/www`;
const USERS_FILE = join(ROOT, "..", "users.txt");
// Post files on the NAS mount (CIFS).
const DATA_ROOT = "/media/plus";
const PUBLIC_ROOT = ROOT;
const TMP_UPLOAD_ROOT = join(ROOT, "upload");
const WATERMARK = join(ROOT, "watermark.png");
const DEFAULT_PORT = 8080;
const LIVE_PORT = 8110;
const SESSION_COOKIE = "plussid";
const USERNAME_COOKIE = "username";
const POST_FILE = "post.txt";
const POSTS_QUERY_VALUE = "plz";
const NO_MORE_POSTS = "nomore";
const DEFAULT_LAST_POST = "999999999999";
const loadbatchsize =20;
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024 * 1024;
const THUMBNAIL_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".mp4"]);
// ----- Auth -----
// Session handling and login state.
// Session storage and login helpers.
const sessions = new Map<string, { usertag: string; createdAt: number }>();

// ----- Utils -----
// Small helper functions used across the app.
// Small shared helpers.
function htmlEscape(input: string): string {
  // Escape unsafe HTML text.
  return input
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#39;");
}

function nl2br(input: string): string {
  // Convert line breaks for HTML.
  return input.replaceAll("\n", "<br>");
}

function sha1Hex(input: string): string {
  // Hash text as SHA1.
  return createHash("sha1").update(input).digest("hex");
}

function md5Hex(input: string): string {
  // Hash text as MD5.
  return createHash("md5").update(input).digest("hex");
}

function parseCookies(req: Request): Record<string, string> {
  // Read cookies from the request.
  const raw = req.headers.get("cookie") ?? "";
  const out: Record<string, string> = {};

  for (const part of raw.split(";")) {
    const trimmed = part.trim();
    if (!trimmed) continue;

    const idx = trimmed.indexOf("=");
    if (idx === -1) continue;

    out[trimmed.slice(0, idx)] = decodeURIComponent(trimmed.slice(idx + 1));
  }

  return out;
}

function setCookie(headers: Headers, key: string, value: string, extras = "Path=/; HttpOnly; SameSite=Lax") {
  // Add one Set-Cookie header.
  headers.append("Set-Cookie", `${key}=${encodeURIComponent(value)}; ${extras}`);
}

function createSession(usertag: string): string {
  // Create a new session id.
  const sid = crypto.randomUUID();
  sessions.set(sid, { usertag, createdAt: Date.now() });
  return sid;
}

function getSession(req: Request): { sid: string; usertag: string } | null {
  // Read the current session.
  const cookies = parseCookies(req);
  const sid = cookies[SESSION_COOKIE];
  if (!sid) return null;

  const session = sessions.get(sid);
  if (!session) return null;

  return { sid, usertag: session.usertag };
}

async function ensureDir(path: string) {
  // Create a folder if needed.
  try {
    await Deno.mkdir(path, { recursive: true });
  } catch (e) {
    const err = e as { code?: string; name?: string };
    if (err.code === "ELOOP" || err.name === "FilesystemLoop") {
      throw new Error(
        `Cannot create directory (symbolic link loop): ${path}. ` +
          `Inspect "${DATA_ROOT}" and parent paths for circular symlinks.`,
      );
    }
    throw e;
  }
}

async function exists(path: string): Promise<boolean> {
  // Check whether a path exists.
  try {
    await Deno.stat(path);
    return true;
  } catch {
    return false;
  }
}

async function isPlusDataOffline(): Promise<boolean> {
  // NAS / post storage unavailable: mount missing or admin flag file.
  if (!(await exists(DATA_ROOT))) return true;
  return await exists(join(DATA_ROOT, ".offline"));
}

async function safeRemoveDir(path: string) {
  // Remove a folder when present.
  if (await exists(path)) {
    await Deno.remove(path, { recursive: true });
  }
}

function timestampNow() {
  // Build a short folder timestamp.
  const d = new Date();
  const pad = (n: number) => String(n).padStart(2, "0");
  return `${String(d.getFullYear()).slice(-2)}${pad(d.getMonth() + 1)}${pad(d.getDate())}${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
}

function fullTimestamp() {
  // Build a full text timestamp.
  const d = new Date();
  const pad = (n: number) => String(n).padStart(2, "0");
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}

function dayPrefix() {
  // Build the default post prefix.
  const d = new Date();
  const pad = (n: number) => String(n).padStart(2, "0");
  return `${pad(d.getDate())}${pad(d.getMonth() + 1)}${String(d.getFullYear()).slice(-2)} `;
}

function contentTypeFor(path: string): string {
  // Pick content type from file name.
  const ext = extname(path).toLowerCase();

  switch (ext) {
    case ".jpg":
    case ".jpeg":
      return "image/jpeg";
    case ".png":
      return "image/png";
    case ".gif":
      return "image/gif";
    case ".webp":
      return "image/webp";
    case ".mp4":
      return "video/mp4";
    case ".mov":
      return "video/quicktime";
    default:
      return "application/octet-stream";
  }
}

function htmlResponse(body: string, headers = new Headers(), status = 200) {
  // Return an HTML response.
  headers.set("content-type", "text/html; charset=utf-8");
  return new Response(body, { headers, status });
}

function textResponse(body: string, headers = new Headers()) {
  // Return a plain text response.
  headers.set("content-type", "text/plain; charset=utf-8");
  return new Response(body, { headers });
}

async function getServerPort() {
  // Pick the correct server port.
  return (await exists("./.live")) ? LIVE_PORT : DEFAULT_PORT;
}

function postFolderPath(postsFolder: string, postId: string) {
  // Build one post folder path.
  return join(postsFolder, postId);
}

function postFilePath(postFolder: string) {
  // Build one post file path.
  return join(postFolder, POST_FILE);
}

function uploadedThumbUrl(sessionId: string, fileName: string) {
  // Build the temp thumbnail URL.
  return `/upload/${encodeURIComponent(sessionId)}/${encodeURIComponent(fileName)}`;
}

// ----- users.ts -----
// User file loading.
async function readUsers(): Promise<Record<string, Record<string, string>>> {
  // Load users from users.txt.
  const txt = await Deno.readTextFile(USERS_FILE);
  const out: Record<string, Record<string, string>> = {};

  for (const line of txt.split(/\r?\n/)) {
    if (!line.trim()) continue;

    const [usertag, username, passwordHash] = line.split(";");
    if (!username || !passwordHash || !usertag) continue;

    out[username] ??= {};
    out[username][passwordHash] = usertag;
  }

  return out;
}

async function tryLogin(req: Request) {
  // Validate posted login data.
  const form = await req.clone().formData().catch(() => null);
  const username = String(form?.get("username") ?? "");
  const password = String(form?.get("password") ?? "");

  if (!username || !password) {
    return { username, usertag: "" };
  }

  const users = await readUsers();
  const passwordHash = sha1Hex(password);
  const usertag = users[username]?.[passwordHash] ?? "";

  return { username, usertag };
}

// ----- posts.ts -----
// Post files and folders.
async function userPostsFolder(usertag: string) {
  // Get the user post folder.
  const folder = join(DATA_ROOT, md5Hex(usertag));
  await ensureDir(folder);
  return folder;
}

function sessionUploadFolder(sessionId: string) {
  // Get the temp upload folder.
  return join(TMP_UPLOAD_ROOT, sessionId);
}

async function copyFiles(fromDir: string, toDir: string) {
  // Copy files from one folder to another.
  for await (const entry of Deno.readDir(fromDir)) {
    if (!entry.isFile) continue;
    await Deno.copyFile(join(fromDir, entry.name), join(toDir, entry.name));
  }
}

async function listPostFiles(postFolder: string) {
  // Split thumbnails and media files.
  const thumbs: string[] = [];
  const media: string[] = [];

  for await (const entry of Deno.readDir(postFolder)) {
    if (!entry.isFile) continue;
    if (entry.name.includes("_plus.jpg")) thumbs.push(entry.name);
    else if (!entry.name.endsWith(".txt")) media.push(entry.name);
  }

  thumbs.sort();
  media.sort();
  return { thumbs, media };
}

async function parsePostFile(path: string) {
  // Read post header and body.
  const raw = await Deno.readTextFile(path);
  let headerText = "";
  let postText = raw;

  const headEnd = raw.indexOf("</head>");
  if (headEnd >= 0) {
    headerText = raw.slice(0, headEnd + 7);
    postText = raw.slice(headEnd + 7);
  }

  const postStart = postText.indexOf("<posttext>");
  const postEnd = postText.indexOf("</posttext>");
  if (postStart >= 0 && postEnd > postStart) {
    postText = postText.slice(postStart + 10, postEnd);
  }

  const headerMatch = headerText.match(/^<head>([^;]*);(.*)<\/head>$/s);
  const headerUsertag = headerMatch?.[1] ?? "";
  const headerDate = headerMatch?.[2] ?? "";

  return { raw, headerText, postText, headerUsertag, headerDate };
}

async function writePostFile(postFolder: string, usertag: string, postText: string) {
  // Save one post text file.
  await Deno.writeTextFile(
    postFilePath(postFolder),
    `<head>${usertag};${fullTimestamp()}</head>\n<posttext>${postText}</posttext>`,
  );
}

async function removeUnselectedMedia(postFolder: string, form: FormData) {
  // Remove media that was unchecked in edit.
  if (!(await exists(postFolder))) return;

  const hasImageFields = String(form.get("has_existing_images") ?? "") === "1";
  if (!hasImageFields) return;

  const selectedIndexes = new Set<number>();

  for (const [key, value] of form.entries()) {
    if (!key.startsWith("images[") || !value) continue;
    const index = Number(key.match(/images\[(\d+)\]/)?.[1] ?? "-1");
    if (index >= 0) selectedIndexes.add(index);
  }

  const { thumbs, media } = await listPostFiles(postFolder);

  for (let index = 0; index < Math.max(thumbs.length, media.length); index++) {
    if (selectedIndexes.has(index)) continue;

    if (thumbs[index] && await exists(join(postFolder, thumbs[index]))) {
      await Deno.remove(join(postFolder, thumbs[index]));
    }
    if (media[index] && await exists(join(postFolder, media[index]))) {
      await Deno.remove(join(postFolder, media[index]));
    }
  }
}

async function moveSessionUploads(sessionId: string, postFolder: string) {
  // Move temp uploads into the post folder.
  const uploadDir = sessionUploadFolder(sessionId);
  if (!(await exists(uploadDir))) return;

  await copyFiles(uploadDir, postFolder);
  await safeRemoveDir(uploadDir);
}

// ----- media.ts -----
// Thumbnail helpers.
async function runCommand(cmd: string[]) {
  // Run one shell command.
  const p = new Deno.Command(cmd[0], {
    args: cmd.slice(1),
    stdout: "piped",
    stderr: "piped",
  });

  const out = await p.output();
  if (out.code !== 0) {
    const stderr = new TextDecoder().decode(out.stderr);
    throw new Error(`${cmd[0]} failed (${out.code}): ${stderr}`);
  }
}

async function generateThumbnail(filePath: string) {
  // Build a thumbnail file.
  const thumbPath = filePath.replace(/\.[^.]+$/, "") + "_plus.jpg";
  const ext = extname(filePath).toLowerCase();

  if (ext === ".mp4") {
    // First try the old offset capture.
    await runCommand(["ffmpeg", "-loglevel", "panic", "-itsoffset", "-4", "-i", filePath, "-y", "-vcodec", "mjpeg", "-vframes", "1", "-an", "-f", "rawvideo", "-s", "500x375", thumbPath]);

    try {
      const stat = await Deno.stat(thumbPath);
      if (stat.size === 0) {
        await runCommand(["ffmpeg", "-loglevel", "panic", "-i", filePath, "-y", "-vcodec", "mjpeg", "-vframes", "1", "-an", "-f", "rawvideo", "-s", "500x375", thumbPath]);
      }
    } catch {
      await runCommand(["ffmpeg", "-loglevel", "panic", "-i", filePath, "-y", "-vcodec", "mjpeg", "-vframes", "1", "-an", "-f", "rawvideo", "-s", "500x375", thumbPath]);
    }

    // Add watermark when available.
    if (await exists(WATERMARK)) {
      await runCommand(["convert", thumbPath, WATERMARK, "-resize", "500x375", "-gravity", "southeast", "-geometry", "+10+10", "-composite", "-quiet", thumbPath]);
    }
  } else {
    // Resize still images.
    await runCommand(["convert", filePath, "-auto-orient", "-resize", "500x375", "-background", "white", "-gravity", "center", "-extent", "500x375", "-quiet", thumbPath]);
  }

  return thumbPath;
}

// ----- render.ts -----
// HTML rendering.
function renderOffline() {
  // Render when NAS post storage is not available.
  return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Plus+ — offline</title></head>
<body>
  <div class="container">
    <p>Plus er offline — NAS-data er ikke tilgængelig.</p>
  </div>
</body>
</html>`;
}

function renderLogin(username = "") {
  // Render the login page.
  return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Plus+</title></head>
<body>
  <div class="container">
    <form method="POST">
      <div class="form-group">
        <input type="text" name="username" value="${htmlEscape(username)}" placeholder="username">
        <br><input type="password" name="password" placeholder="password">
      </div>
      <button type="submit">Login</button>
    </form>
  </div>
</body>
</html>`;
}

function renderMain(postIndexHtml: string) {
  // Render the main app page.
  return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Plus+</title>
<style>
html, body {height:100%;margin:0;padding:0;background-color:lightgrey;background-attachment:fixed;background-repeat:no-repeat;background-image:url("profile.jpg");background-position:center;color:WhiteSmoke;background-size:1200px 560px;}
a {color:WhiteSmoke;}
.box {display:inline-block;width:150px;height:120px;background:lightgrey;margin:2px;text-align:center}
.box .name {height:25px;margin-top:5px}
.box .progresscontainer {height:5px;margin:80px 5px 0}
.box .progress {background:grey;height:5px;width:1%}
.box.picture {background:none center top no-repeat;background-size:100%}
.box.picture .progresscontainer, .box.picture .name {display:none}
.posts {position:relative;width:calc(100% - 10px);min-height:200px}
.post {position:absolute;left:0;top:0;width:470px;background:white;margin:0;border-radius:6px;box-sizing:border-box;opacity:0;transform:translateY(12px);transition:opacity 0.22s ease,transform 0.22s ease;}
.post .text {background:white;font-size:14px;color:rgba(0,0,0,0.87);font-family:Roboto,Helvetica,Arial,sans-serif;margin:5px;line-height:20px}
.post .postheadtext{background:WhiteSmoke;text-align:right;color:grey;font-size:10px}
.post .postfoottext{background:WhiteSmoke;text-align:right;color:grey;font-size:10px;cursor:copy}
.editpost{background:WhiteSmoke;color:black;font-size:12px;text-align:center;height:25px;max-width:470px}
.posthead{background:WhiteSmoke;color:black;font-size:12px;text-align:left;height:25px}
.attachments {display:flex;flex-wrap:wrap;gap:4px;margin:2px}
.attachment {margin:0;max-width:470px}
.attachment img {display:block;height:auto;border-radius:4px}
.picbox {display:inline-block;width:120px;height:100px;background:lightgrey;margin:8px;text-align:center}
.newpost {max-width:470px;background:#d4d0c8;border:2px solid #808080;border-top-color:#ffffff;border-left-color:#ffffff;padding:0;box-shadow:inset -1px -1px 0 #ffffff,inset 1px 1px 0 #808080;}
.newpost .windowtitle {height:24px;line-height:24px;padding:0 6px;background:linear-gradient(to bottom,#0a246a,#3a6ea5);color:#fff;font-family:Tahoma,Arial,sans-serif;font-size:12px;font-weight:bold;position:relative;border-bottom:1px solid #808080;}
.newpost .windowbody {padding:6px;}
.newpost .close {position:absolute;top:3px;right:3px;width:18px;height:18px;padding:0;background:#d4d0c8;border:1px solid #808080;border-top-color:#ffffff;border-left-color:#ffffff;border-radius:0;font-family:Tahoma,Arial,sans-serif;font-size:12px;line-height:14px;cursor:pointer;}
.newpost .close:active {border-top-color:#404040;border-left-color:#404040;border-bottom-color:#ffffff;border-right-color:#ffffff;}
.newpost textarea {background:#ffffff;border:2px solid #808080;border-top-color:#404040;border-left-color:#404040;padding:4px;font-family:Tahoma,Arial,sans-serif;font-size:12px;color:#000;box-sizing:border-box;}
.newpost input[type="submit"] {background:#d4d0c8;border:2px solid #808080;border-top-color:#ffffff;border-left-color:#ffffff;padding:2px 10px;font-family:Tahoma,Arial,sans-serif;font-size:12px;color:#000;cursor:pointer;}
.newpost .workingindicator {display:none;background:#d4d0c8;border:2px solid #808080;border-top-color:#404040;border-left-color:#404040;padding:2px 10px;font-family:Tahoma,Arial,sans-serif;font-size:12px;color:#000;}
.newpost input[type="submit"]:active {border-top-color:#404040;border-left-color:#404040;border-bottom-color:#ffffff;border-right-color:#ffffff;}
</style>
<script src="https://code.jquery.com/jquery-1.6.2.min.js"></script>
<script>
var upload_ok="";
var postState = { uploads: 0, submitting: false };
var tpl_box = "<div class='box'><div class='name'></div><div class='progresscontainer'><div class='progress'></div></div></div>";
var lastpostid="";
var loadworking="free";
var loadeditworking="free";
var masonryGap=8;
var masonryColumnWidth=472;
var masonryResizeTimer=null;
var masonryRowTolerance=999999;

function refreshPostUi() {
  // Update UI based on upload/submit state.
  var isBusy = postState.submitting || postState.uploads > 0;
  $('.mysubmit').css('display', isBusy ? 'none' : 'inline-block').prop('disabled', isBusy);
  $('.workingindicator').css('display', isBusy ? 'inline-block' : 'none');
}

function beginUpload() {
  // Mark that an upload has started.
  postState.uploads += 1;
  refreshPostUi();
}

function endUpload() {
  // Mark that an upload has finished.
  if (postState.uploads > 0) postState.uploads -= 1;
  refreshPostUi();
}

function beginSubmit() {
  // Mark that the form is being submitted.
  postState.submitting = true;
  refreshPostUi();
}

function resetPostUi() {
  // Reset UI back to idle state.
  postState.uploads = 0;
  postState.submitting = false;
  refreshPostUi();
}

$(document).ready(function() {
  if (!window.File || !window.FileReader || !window.FileList || !window.Blob) {
    alert('The File APIs are not fully supported in this browser. Please upgrade your browser.');
  }
  document.body.addEventListener('dragover', function(e){ e.stopPropagation(); e.preventDefault(); }, false);
  document.body.addEventListener('drop', function(e){ e.stopPropagation(); e.preventDefault(); upload_files(e.dataTransfer.files); }, false);
  window.addEventListener('resize', function() {
    clearTimeout(masonryResizeTimer);
    masonryResizeTimer = setTimeout(layoutPosts, 80);
  }, false);
});

function upload_files(files) {
  // Loop through dropped files and upload them.
  for (var i = 0, f; f = files[i]; i++) if (upload_ok === 'yep') upload_file(f);
}

function upload_file(f) {
  // Upload one file with progress bar.
  beginUpload();
  var name = f.name;
  if (name.length > 50) name = name.substr(0, 50) + '...';
  var $box = $(tpl_box).find('.name').html(name).end();
  var XHR = new XMLHttpRequest();
  XHR.open('PUT', '?upl=' + encodeURIComponent(f.name), true);
  XHR.upload.addEventListener('progress', function(e){
    if (!e.lengthComputable) return;
    var percentComplete = parseInt(e.loaded / e.total * 100);
    $box.find('.progress').css('width', percentComplete + '%');
  }, false);
  XHR.onreadystatechange = function() {
    if (this.readyState == this.DONE) {
      if (this.status === 200) {
        $box.addClass('picture').css('background-image', 'url("' + this.responseText + '")');
      } else {
        $box.find('.name').html('Fejl: ' + this.status);
        $box.find('.progress').css('background', 'red');
      }
      endUpload();
    }
  };
  XHR.send(f);
  $('.newpost').append($box);
}

function loadmore() {
  // Load more posts from the server.
  if (loadworking !== 'free') return;
  loadworking = 'working';
  var XHR = new XMLHttpRequest();
  XHR.withCredentials = true;
  XHR.open('GET', '?psts=plz&lastpost=' + lastpostid, true);
  XHR.onreadystatechange = function() {
    if (this.readyState == this.DONE) {
      var el = document.createElement('html');
      el.innerHTML = this.responseText;
      if (el.getElementsByTagName('lastid').length > 0) {
        lastpostid = el.getElementsByTagName('lastid')[0].innerHTML;
        $('.posts').append(el.getElementsByTagName('posts')[0].innerHTML);
        waitForPostMedia(layoutPosts);
        loadworking = 'free';
      } else {
        $('.loadmore').css('visibility','hidden');
      }
    }
  };
  XHR.send();
}

function waitForPostMedia(done) {
  // Wait until post images know their height.
  var $images = $('.posts img');
  var pending = 0;

  $images.each(function() {
    if (this.complete) return;
    pending += 1;
    $(this).one('load error', function() {
      pending -= 1;
      if (pending === 0) done();
    });
  });

  if (pending === 0) done();
}

function layoutPosts() {
  // Arrange posts in a grid layout.
  // Place posts in strict left-to-right rows.
  var $wrap = $('.posts');
  var $items = $wrap.children('.post');
  if ($items.length === 0) return;

  var wrapWidth = $wrap.innerWidth();
  var columns = Math.max(1, Math.floor((wrapWidth + masonryGap) / (masonryColumnWidth + masonryGap)));
  var rowHeights = [];
  var maxHeight = 0;

  $items.each(function(index) {
    var row = Math.floor(index / columns);
    var col = index % columns;
    var left = col * (masonryColumnWidth + masonryGap);

    if (rowHeights[row] == null) {
      rowHeights[row] = 0;
    }

    $(this).css({ left: left + 'px', top: '0px' });
    var itemHeight = $(this).outerHeight(true);
    if (itemHeight > rowHeights[row]) rowHeights[row] = itemHeight;
  });

  var usedWidth = columns * masonryColumnWidth + (columns - 1) * masonryGap;
  var offsetLeft = Math.max(0, Math.floor((wrapWidth - usedWidth) / 2));
  var rowTop = 0;

  for (var row = 0; row < rowHeights.length; row++) {
    var rowStart = row * columns;
    var rowEnd = Math.min(rowStart + columns, $items.length);

    for (var i = rowStart; i < rowEnd; i++) {
      var col = i % columns;
      var left = offsetLeft + col * (masonryColumnWidth + masonryGap);
      $($items[i]).css({ left: left + 'px', top: rowTop + 'px' });
    }

    rowTop += rowHeights[row] + masonryGap;
  }

  maxHeight = rowTop > 0 ? rowTop - masonryGap : 0;
  $wrap.height(maxHeight);
  $items.each(function(index) {
    var $item = $(this);
    if ($item.attr('data-shown') === '1') return;
    setTimeout(function() {
      $item.css({ opacity: '1', transform: 'translateY(0px)' }).attr('data-shown','1');
    }, index * 70);
  });
}

function startfrom(newpos) { lastpostid = newpos; $('.posts').empty().height(0); loadmore(); }
function unHide(CurrentTag) { CurrentTag.nextSibling.style.visibility = 'visible'; CurrentTag.nextSibling.style.position = ''; CurrentTag.remove(); layoutPosts(); }
function editPost(PostId) {
  // Open edit view for a post.
  var doc = document.documentElement;
  var top = (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0);
  $('.newpost').css('top', top + 10);
  upload_ok = 'yep';
  $('.newbutton').empty();
  $('.newpost').empty().append('Henter postId : ' + PostId).css('visibility','visible');
  if (loadeditworking !== 'free') return;
  loadeditworking = 'working';
  var XHR = new XMLHttpRequest();
  XHR.withCredentials = true;
  XHR.open('GET', '?editpsts=plz&editpost=' + PostId, true);
  XHR.onreadystatechange = function() {
    if (this.readyState == this.DONE) {
      $('.newpost').empty().append(this.responseText);
      loadeditworking = 'free';
    }
  };
  XHR.send();
}
function newpost() {
  // Open new post window.
  var doc = document.documentElement;
  var top = (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0);
  $('.newpost').css('top', top + 50).css('visibility','visible');
  $('.newbutton').css('visibility','hidden');
  upload_ok = 'yep';
  resetPostUi();
}

$(document).delegate('.newpost form', 'submit', function() {
  if (postState.uploads > 0 || postState.submitting) return false;
  beginSubmit();
});
</script>
</head>
<body>
<table width="100%"><tr><td><div class="posts"></div></td><td width="200" valign="top">Plus+<br><br>Index<br>${postIndexHtml}</td></tr>
<tr><td width="100%" align="center"><button class="loadmore" type="button" onClick="loadmore();">load more</button></td></tr></table>
<div class="newpost" style="position:absolute; top:10px; left:35%;visibility:hidden;">
  <div class="windowtitle">New post<button class="close" type="button" onClick="$('.newpost').css('visibility','hidden');$('.newbutton').css('visibility','visible');upload_ok='no';resetPostUi();"><b>&times;</b></button></div>
  <div class="windowbody">
  <form method="post" action="/">
    <textarea class="myposttextarea" name="mypost" rows="10" style="width:100%;" autofocus>${dayPrefix()}</textarea>
    <br><div style="width:500px;"><input class="mysubmit" type="submit"><span class="workingindicator">Arbejder...</span></div>
  </form>
  </div>
</div>
<div class="newbutton" style="position:fixed; bottom:50px; right:50px;cursor:pointer;"><img src="newpost.png" onClick="newpost();"></div>
<script>loadmore();</script>
</body>
</html>`;
}

async function buildPostIndex(postsFolder: string) {
  // Build the month index links.
  const postindex: Record<string, Record<string, number>> = {};

  for await (const entry of Deno.readDir(postsFolder)) {
    if (!entry.isDirectory || entry.name.length < 4) continue;

    const y = String(Number(entry.name.slice(0, 2))).padStart(2, "0");
    const m = String(Number(entry.name.slice(2, 4))).padStart(2, "0");
    postindex[y] ??= {};
    postindex[y][m] = (postindex[y][m] ?? 0) + 1;
  }

  let html = "";
  for (let i = 99; i >= 0; i--) {
    for (let j = 12; j >= 1; j--) {
      const y = String(i).padStart(2, "0");
      const m = String(j).padStart(2, "0");
      if (!postindex[y]?.[m]) continue;

      const postFolder = `${y}${m}99999999`;
      html += `<nobr><a href="#" onclick="startfrom(${postFolder})">${y}-${m}/${postindex[y][m]}</a><br>`;
    }
  }

  return html;
}

// ----- routes_posts.ts -----
// Post related request handlers.
async function handleNewPost(req: Request, usertag: string, sessionId: string) {
  // Save a new post.
  const form = await req.formData();
  const mypost = String(form.get("mypost") ?? "");
  if (!mypost || mypost === dayPrefix()) return null;

  const postsfolder = await userPostsFolder(usertag);
  const postFolder = postFolderPath(postsfolder, timestampNow());
  await ensureDir(postFolder);

  await writePostFile(postFolder, usertag, mypost);
  await moveSessionUploads(sessionId, postFolder);

  return htmlResponse(`<script>window.location = window.location.href</script>`);
}

async function handleUpdatePost(req: Request, usertag: string, sessionId: string) {
  // Save an edited post.
  const form = await req.formData();
  const oldfolder = String(form.get("oldfolder") ?? "");
  if (!oldfolder) return null;

  const updatepost = String(form.get("updatepost") ?? "");
  const postsfolder = await userPostsFolder(usertag);
  const targetPostFolder = postFolderPath(postsfolder, oldfolder);

  await writePostFile(targetPostFolder, usertag, updatepost);
  await removeUnselectedMedia(targetPostFolder, form);
  await moveSessionUploads(sessionId, targetPostFolder);

  return htmlResponse(`<script>window.location = "/"</script>`);
}

async function handleImageRequest(url: URL, usertag: string) {
  // Return one thumbnail image.
  const imageIdx = Number(url.searchParams.get("Image") ?? "0");
  const postId = String(url.searchParams.get("Post") ?? "");
  const postsfolder = await userPostsFolder(usertag);
  const postFolder = postFolderPath(postsfolder, postId);
  const { thumbs } = await listPostFiles(postFolder);
  const file = thumbs[imageIdx];
  if (!file) return new Response("Not found", { status: 404 });

  const path = join(postFolder, file);
  const stat = await Deno.stat(path);
  const readable = (await Deno.open(path, { read: true })).readable;

  return new Response(readable, {
    headers: {
      "content-type": contentTypeFor(path),
      "content-length": String(stat.size),
    },
  });
}

async function handleMediaRequest(url: URL, usertag: string) {
  // Return one media file.
  const mediaIdx = Number(url.searchParams.get("Media") ?? "0");
  const postId = String(url.searchParams.get("Post") ?? "");
  const postsfolder = await userPostsFolder(usertag);
  const postFolder = postFolderPath(postsfolder, postId);
  const { media } = await listPostFiles(postFolder);
  const file = media[mediaIdx];
  if (!file) return new Response("Not found", { status: 404 });

  const path = join(postFolder, file);
  const stat = await Deno.stat(path);
  const readable = (await Deno.open(path, { read: true })).readable;

  return new Response(readable, {
    headers: {
      "content-type": contentTypeFor(path),
      "content-length": String(stat.size),
    },
  });
}

async function handleEditPostRequest(url: URL, usertag: string) {
  // Return the edit form HTML.
  const postId = String(url.searchParams.get("editpost") ?? "");
  const postsfolder = await userPostsFolder(usertag);
  const postFolder = postFolderPath(postsfolder, postId);
  const filePath = postFilePath(postFolder);
  if (!(await exists(filePath))) return new Response("", { status: 404 });

  const { postText, headerUsertag } = await parsePostFile(filePath);
  const { thumbs } = await listPostFiles(postFolder);

  let mediaHtml = "";
  if (thumbs.length) {
    mediaHtml += `<input type="hidden" name="has_existing_images" value="1">`;
    mediaHtml += `<div class='attachment'>`;
    thumbs.forEach((_, i) => {
      mediaHtml += `<div class="picbox"><img width="110" src="?Post=${encodeURIComponent(postId)}&Image=${i}"><input type="checkbox" name="images[${i}]" value="1" checked></div>`;
    });
    mediaHtml += `</div>`;
  }

  const html = `
<div class="windowtitle">Edit post<button class="close" type="button" onClick="$('.newpost').css('visibility','hidden');$('.newbutton').css('visibility','visible');upload_ok='no';resetPostUi();"><b>&times;</b></button></div>
<div class="windowbody">
<form method="post" action="/">
<div class="posthead">Post Header <input type="text" size="40" name="usertag" value="${htmlEscape(headerUsertag)}"></div>
<div class="posthead">Post Folder <input type="text" name="postfolder" value="${htmlEscape(postId)}"></div>
<input type="hidden" name="oldfolder" value="${htmlEscape(postId)}">
<textarea class="myposttextarea" name="updatepost" rows="10" style="width:100%;" autofocus>${htmlEscape(postText)}</textarea>
<br><div style="width:500px;"><input class="mysubmit" type="submit"><span class="workingindicator">Arbejder...</span></div>
${mediaHtml}
</form></div>`;

  return htmlResponse(html);
}

async function handlePostsRequest(url: URL, usertag: string) {
  // Return more rendered posts.
  const postsfolder = await userPostsFolder(usertag);
  let lastpost = String(url.searchParams.get("lastpost") ?? "");
  if (!lastpost) lastpost = DEFAULT_LAST_POST;
  if (lastpost === NO_MORE_POSTS) return textResponse("");

  const posts: string[] = [];
  for await (const entry of Deno.readDir(postsfolder)) {
    if (entry.isDirectory && entry.name < lastpost) posts.push(entry.name);
  }

  posts.sort((a, b) => b.localeCompare(a));

  let latestpost = lastpost;
  let html = "<html><posts>";

  for (const postId of posts.slice(0, loadbatchsize)) {
    latestpost = postId;
    const postFolder = postFolderPath(postsfolder, postId);
    const { headerText, postText: rawPostText } = await parsePostFile(postFilePath(postFolder));
    const { thumbs } = await listPostFiles(postFolder);

    let postText = rawPostText;
    if (postText.length > 120) {
      let maxText = 250;
      while (maxText < postText.length && postText[maxText] !== " ") maxText++;
      postText = htmlEscape(postText.slice(0, maxText)) + `<span onClick="unHide(this);">...</span><span style='visibility:hidden;position:absolute;'>` + htmlEscape(postText.slice(maxText)) + `</span>`;
    } else {
      postText = htmlEscape(postText);
    }

    html += `<div class='post'><table>`;
    if (headerText) html += `<tr><td><div class='postheadtext'>${htmlEscape(headerText.replace(/<[^>]+>/g, ""))}</div></td></tr>`;
    html += `<tr><td><div class='text'>${nl2br(postText)}</div></td></tr>`;
    html += `<tr><td><div class='attachments'>`;

    let imgWidth = 460;
    const images = thumbs.length;
    if (images >= 2) imgWidth = 225;
    if (images > 2) imgWidth = 210;
    if (images === 3) imgWidth = 285;

    thumbs.forEach((_, key) => {
      html += `<div class='attachment'><a href='?Post=${encodeURIComponent(postId)}&Media=${key}' target='_blank'><img width='${imgWidth}' src='?Post=${encodeURIComponent(postId)}&Image=${key}'></a></div>`;
      if (images === 3) imgWidth = 160;
      if (images > 3) imgWidth = 120;
    });

    html += `</div></td></tr>`;
    html += `<tr><td><div class='postfoottext' onClick='editPost("${htmlEscape(postId)}")'>${htmlEscape(postId)}</div></td></tr></table></div>`;
  }

  html += `</posts>`;
  html += `<lastid>${latestpost === lastpost ? NO_MORE_POSTS : latestpost}</lastid></html>`;
  return htmlResponse(html);
}

// ----- routes_upload.ts -----
// Upload handlers.
async function handleUpload(req: Request, url: URL, sessionId: string) {
  // Receive and save uploaded file stream.
  try {
    const originalName = String(url.searchParams.get("upl") ?? "upload.bin");
    const safeName = basename(originalName).toLowerCase();
    const uploadDir = sessionUploadFolder(sessionId);
    await ensureDir(uploadDir);

    const filePath = join(uploadDir, safeName);
    const ext = extname(filePath).toLowerCase();
    const contentLength = Number(req.headers.get("content-length") ?? "0");

    if (!req.body) {
      return new Response("Missing upload body", { status: 400 });
    }

    if (Number.isFinite(contentLength) && contentLength > MAX_UPLOAD_BYTES) {
      return new Response("File too large", { status: 413 });
    }

    const file = await Deno.open(filePath, { write: true, create: true, truncate: true });
    let bytesWritten = 0;

    try {
      const countingStream = new TransformStream<Uint8Array, Uint8Array>({
        transform(chunk, controller) {
          bytesWritten += chunk.byteLength;
          if (bytesWritten > MAX_UPLOAD_BYTES) {
            throw new Error("Upload exceeds size limit");
          }
          controller.enqueue(chunk);
        },
      });

      await req.body.pipeThrough(countingStream).pipeTo(file.writable);
    } catch (err) {
      try {
        file.close();
      } catch {
        // Ignore close errors after failed stream.
      }

      if (await exists(filePath)) {
        await Deno.remove(filePath).catch(() => {});
      }

      const message = err instanceof Error ? err.message : String(err);
      console.error("Upload failed:", err);

      if (message.includes("size limit")) {
        return new Response("File too large", { status: 413 });
      }

      return new Response("Upload failed", { status: 500 });
    }

    let thumbPath = "";
    if (THUMBNAIL_EXTENSIONS.has(ext)) {
      try {
        thumbPath = await generateThumbnail(filePath);
      } catch (err) {
        console.error("Thumbnail generation failed:", err);
      }
    }

    return textResponse(uploadedThumbUrl(sessionId, basename(thumbPath || filePath)));
  } catch (err) {
    console.error("handleUpload failed:", err);
    return new Response("Upload failed", { status: 500 });
  }
}

// ----- static.ts -----
// Static file serving.
async function tryServeStatic(url: URL) {
  // Serve files from the public folder.
  const pathname = decodeURIComponent(url.pathname);
  if (pathname === "/") return null;

  const relativePath = pathname.replace(/^\/+/, "");
  const filePath = join(PUBLIC_ROOT, relativePath);

  try {
    const stat = await Deno.stat(filePath);
    if (!stat.isFile) return null;

    const file = await Deno.open(filePath, { read: true });
    return new Response(file.readable, {
      headers: {
        "content-type": contentTypeFor(filePath),
        "content-length": String(stat.size),
      },
    });
  } catch {
    return null;
  }
}

// ----- server.ts -----
// Main router and startup.
async function handleLogin(req: Request) {
  // Handle login post requests.
  const { username, usertag } = await tryLogin(req);
  if (!usertag) return htmlResponse(renderLogin(username));

  const sessionId = createSession(usertag);
  const headers = new Headers({ location: "/" });
  setCookie(headers, SESSION_COOKIE, sessionId);
  setCookie(headers, USERNAME_COOKIE, username, "Path=/; SameSite=Lax");
  return new Response("", { status: 302, headers });
}

// ----- Routing -----
// Main request routing for logged-in users.
async function handleAuthedRequest(req: Request, url: URL, usertag: string, sessionId: string, hadSession: boolean) {
  // Route all logged-in requests.
  if (url.searchParams.get("upl")) {
    return await handleUpload(req, url, sessionId);
  }
  if (url.searchParams.has("Image")) {
    return await handleImageRequest(url, usertag);
  }
  if (url.searchParams.has("Media")) {
    return await handleMediaRequest(url, usertag);
  }
  if (url.searchParams.get("editpsts") === POSTS_QUERY_VALUE) {
    return await handleEditPostRequest(url, usertag);
  }
  if (url.searchParams.get("psts") === POSTS_QUERY_VALUE) {
    return await handlePostsRequest(url, usertag);
  }

  if (req.method === "POST") {
    const form = await req.clone().formData();

    if (form.get("oldfolder")) {
      const res = await handleUpdatePost(req, usertag, sessionId);
      if (res) return res;
    }

    if (form.get("mypost")) {
      const res = await handleNewPost(req, usertag, sessionId);
      if (res) return res;
    }
  }

  // Clear temp uploads before showing the page.
  await safeRemoveDir(sessionUploadFolder(sessionId));

  const postsfolder = await userPostsFolder(usertag);
  const indexHtml = await buildPostIndex(postsfolder);
  const headers = new Headers();
  if (!hadSession) setCookie(headers, SESSION_COOKIE, sessionId);

  return htmlResponse(renderMain(indexHtml), headers);
}

const serverPort = await getServerPort();

serve(async (req) => {
  // Handle one HTTP request.
  const url = new URL(req.url);

  if (await isPlusDataOffline()) {
    return htmlResponse(renderOffline(), new Headers(), 503);
  }

  const staticRes = await tryServeStatic(url);
  if (staticRes) return staticRes;

  const session = getSession(req);
  const usertag = session?.usertag ?? "";
  let sessionId = session?.sid ?? "";

  if (!usertag && req.method === "POST") {
    return await handleLogin(req);
  }

  if (!usertag) {
    const cookies = parseCookies(req);
    return htmlResponse(renderLogin(cookies[USERNAME_COOKIE] ?? ""));
  }

  if (!sessionId) {
    sessionId = createSession(usertag);
  }

  return await handleAuthedRequest(req, url, usertag, sessionId, Boolean(session));
}, { port: serverPort });



