code:
import { Tiktoken } from "tiktoken/lite";
import cl100k_base from "tiktoken/encoders/cl100k_base.json";

let encoder: Tiktoken;

export function init() {
  encoder = new Tiktoken(
    cl100k_base.bpe_ranks,
    cl100k_base.special_tokens,
    cl100k_base.pat_str
  );
  return true;
}

// Tested against:
// https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb

export function getTokenCount(
  prompt: string | OpenAIPromptMessage[],
  model: string
) {
  if (typeof prompt === "string") {
    return getTextTokenCount(prompt);
  }

  const gpt4 = model.startsWith("gpt-4");

  const tokensPerMessage = gpt4 ? 3 : 4;
  const tokensPerName = gpt4 ? 1 : -1; // turbo omits role if name is present

  let numTokens = 0;

  for (const message of prompt) {
    numTokens += tokensPerMessage;
    for (const key of Object.keys(message)) {
      {
        const value = message[key as keyof OpenAIPromptMessage];
        if (!value || typeof value !== "string") continue;
        // Break if we get a huge message or exceed the token limit to prevent
        // DoS.
        // 100k tokens allows for future 100k GPT-4 models and 500k characters
        // is just a sanity check
        if (value.length > 500000 || numTokens > 100000) {
          numTokens = 100000;
          return {
            tokenizer: "tiktoken (prompt length limit exceeded)",
            token_count: numTokens,
          };
        }

        numTokens += encoder.encode(value).length;
        if (key === "name") {
          numTokens += tokensPerName;
        }
      }
    }
  }
  numTokens += 3; // every reply is primed with <|start|>assistant<|message|>
  return { tokenizer: "tiktoken", token_count: numTokens };
}

function getTextTokenCount(prompt: string) {
  if (prompt.length > 500000) {
    return {
      tokenizer: "length fallback",
      token_count: 100000,
    };
  }

  return {
    tokenizer: "tiktoken",
    token_count: encoder.encode(prompt).length,
  };
}

export type OpenAIPromptMessage = {
  name?: string;
  content: string;
  role: string;
};

// Model	Resolution	Price
// DALL·E 3	1024×1024	$0.040 / image
// 1024×1792, 1792×1024	$0.080 / image
// DALL·E 3 HD	1024×1024	$0.080 / image
// 1024×1792, 1792×1024	$0.120 / image
// DALL·E 2	1024×1024	$0.020 / image
// 512×512	$0.018 / image
// 256×256	$0.016 / image

export const DALLE_TOKENS_PER_DOLLAR = 100000;

/**
 * OpenAI image generation with DALL-E doesn't use tokens but everything else
 * in the application does. There is a fixed cost for each image generation
 * request depending on the model and selected quality/resolution parameters,
 * which we convert to tokens at a rate of 100000 tokens per dollar.
 */
export function getOpenAIImageCost(params: {
  model: "dall-e-2" | "dall-e-3";
  quality: "standard" | "hd";
  resolution: "512x512" | "256x256" | "1024x1024" | "1024x1792" | "1792x1024";
  n: number | null;
}) {
  const { model, quality, resolution, n } = params;
  const usd = (() => {
    switch (model) {
      case "dall-e-2":
        switch (resolution) {
          case "512x512":
            return 0.018;
          case "256x256":
            return 0.016;
          case "1024x1024":
            return 0.02;
          default:
            throw new Error("Invalid resolution");
        }
      case "dall-e-3":
        switch (resolution) {
          case "1024x1024":
            return quality === "standard" ? 0.04 : 0.08;
          case "1024x1792":
          case "1792x1024":
            return quality === "standard" ? 0.08 : 0.12;
          default:
            throw new Error("Invalid resolution");
        }
      default:
        throw new Error("Invalid image generation model");
    }
  })();

  const tokens = (n ?? 1) * (usd * DALLE_TOKENS_PER_DOLLAR);

  return {
    tokenizer: `openai-image cost`,
    token_count: Math.ceil(tokens),
  };
}


code:
import { Request } from "express";
import { assertNever } from "../utils";
import {
  init as initClaude,
  getTokenCount as getClaudeTokenCount,
} from "./claude";
import {
  init as initOpenAi,
  getTokenCount as getOpenAITokenCount,
  OpenAIPromptMessage,
  getOpenAIImageCost,
} from "./openai";
import { APIFormat } from "../key-management";

export async function init() {
  initClaude();
  initOpenAi();
}

/** Tagged union via `service` field of the different types of requests that can
 * be made to the tokenization service, for both prompts and completions */
type TokenCountRequest = { req: Request } & (
  | { prompt: OpenAIPromptMessage[]; completion?: never; service: "openai" }
  | {
      prompt: string;
      completion?: never;
      service: "openai-text" | "anthropic" | "google-palm";
    }
  | { prompt?: never; completion: string; service: APIFormat }
  | { prompt?: never; completion?: never; service: "openai-image" }
);

type TokenCountResult = {
  token_count: number;
  tokenizer: string;
  tokenization_duration_ms: number;
};

export async function countTokens({
  req,
  service,
  prompt,
  completion,
}: TokenCountRequest): Promise<TokenCountResult> {
  const time = process.hrtime();
  switch (service) {
    case "anthropic":
      return {
        ...getClaudeTokenCount(prompt ?? completion, req.body.model),
        tokenization_duration_ms: getElapsedMs(time),
      };
    case "openai":
    case "openai-text":
      return {
        ...getOpenAITokenCount(prompt ?? completion, req.body.model),
        tokenization_duration_ms: getElapsedMs(time),
      };
    case "openai-image":
      return {
        ...getOpenAIImageCost({
          model: req.body.model,
          quality: req.body.quality,
          resolution: req.body.size,
          n: parseInt(req.body.n, 10) || null,
        }),
        tokenization_duration_ms: getElapsedMs(time),
      };
    case "google-palm":
      // TODO: Can't find a tokenization library for PaLM. There is an API
      // endpoint for it but it adds significant latency to the request.
      return {
        ...getOpenAITokenCount(prompt ?? completion, req.body.model),
        tokenization_duration_ms: getElapsedMs(time),
      };
    default:
      assertNever(service);
  }
}

function getElapsedMs(time: [number, number]) {
  const diff = process.hrtime(time);
  return diff[0] * 1000 + diff[1] / 1e6;
}

repo_name: valadaptive/oai

license: unknown


code:
import { ZodType, z } from "zod";
import type { ModelFamily } from "../models";
import { makeOptionalPropsNullable } from "../utils";

export const tokenCountsSchema: ZodType<UserTokenCounts> = z.object({
  turbo: z.number().optional().default(0),
  gpt4: z.number().optional().default(0),
  "gpt4-32k": z.number().optional().default(0),
  "gpt4-turbo": z.number().optional().default(0),
  "dall-e": z.number().optional().default(0),
  claude: z.number().optional().default(0),
  bison: z.number().optional().default(0),
  "aws-claude": z.number().optional().default(0),
});

export const UserSchema = z
  .object({
    /** User's personal access token. */
    token: z.string(),
    /** IP addresses the user has connected from. */
    ip: z.array(z.string()),
    /** User's nickname. */
    nickname: z.string().max(80).optional(),
    /**
     * The user's privilege level.
     * - `normal`: Default role. Subject to usual rate limits and quotas.
     * - `special`: Special role. Higher quotas and exempt from
     *   auto-ban/lockout.
     **/
    type: z.enum(["normal", "special", "temporary"]),
    /** Number of prompts the user has made. */
    promptCount: z.number(),
    /**
     * @deprecated Use `tokenCounts` instead.
     * Never used; retained for backwards compatibility.
     */
    tokenCount: z.any().optional(),
    /** Number of tokens the user has consumed, by model family. */
    tokenCounts: tokenCountsSchema,
    /** Maximum number of tokens the user can consume, by model family. */
    tokenLimits: tokenCountsSchema,
    /** Time at which the user was created. */
    createdAt: z.number(),
    /** Time at which the user last connected. */
    lastUsedAt: z.number().optional(),
    /** Time at which the user was disabled, if applicable. */
    disabledAt: z.number().optional(),
    /** Reason for which the user was disabled, if applicable. */
    disabledReason: z.string().optional(),
    /** Time at which the user will expire and be disabled (for temp users). */
    expiresAt: z.number().optional(),
    /** The user's maximum number of IP addresses; supercedes global max. */
    maxIps: z.coerce.number().int().min(0).optional(),
    /** Private note about the user. */
    adminNote: z.string().optional(),
  })
  .strict();

/**
 * Variant of `UserSchema` which allows for partial updates, and makes any
 * optional properties on the base schema nullable. Null values are used to
 * indicate that the property should be deleted from the user object.
 */
export const UserPartialSchema = makeOptionalPropsNullable(UserSchema)
  .partial()
  .extend({ token: z.string() });

export type UserTokenCounts = {
  [K in ModelFamily]?: number;
};
export type User = z.infer<typeof UserSchema>;
export type UserUpdate = z.infer<typeof UserPartialSchema>;


code:
/**
 * Basic user management. Handles creation and tracking of proxy users, personal
 * access tokens, and quota management. Supports in-memory and Firebase Realtime
 * Database persistence stores.
 *
 * Users are identified solely by their personal access token. The token is
 * used to authenticate the user for all proxied requests.
 */

import admin from "firebase-admin";
import schedule from "node-schedule";
import { v4 as uuid } from "uuid";
import { config, getFirebaseApp } from "../../config";
import {
  getClaudeModelFamily,
  getGooglePalmModelFamily,
  getOpenAIModelFamily,
  MODEL_FAMILIES,
  ModelFamily,
} from "../models";
import { logger } from "../../logger";
import { User, UserTokenCounts, UserUpdate } from "./schema";
import { APIFormat } from "../key-management";
import { assertNever } from "../utils";

const log = logger.child({ module: "users" });

const INITIAL_TOKENS: Required<UserTokenCounts> = {
  turbo: 0,
  gpt4: 0,
  "gpt4-32k": 0,
  "gpt4-turbo": 0,
  "dall-e": 0,
  claude: 0,
  bison: 0,
  "aws-claude": 0,
};

const users: Map<string, User> = new Map();
const usersToFlush = new Set<string>();
let quotaRefreshJob: schedule.Job | null = null;
let userCleanupJob: schedule.Job | null = null;

export async function init() {
  log.info({ store: config.gatekeeperStore }, "Initializing user store...");
  if (config.gatekeeperStore === "firebase_rtdb") {
    await initFirebase();
  }
  if (config.quotaRefreshPeriod) {
    const crontab = getRefreshCrontab();
    quotaRefreshJob = schedule.scheduleJob(crontab, refreshAllQuotas);
    if (!quotaRefreshJob) {
      throw new Error(
        "Unable to schedule quota refresh. Is QUOTA_REFRESH_PERIOD set correctly?"
      );
    }
    log.debug(
      { nextRefresh: quotaRefreshJob.nextInvocation() },
      "Scheduled token quota refresh."
    );
  }

  userCleanupJob = schedule.scheduleJob("* * * * *", cleanupExpiredTokens);

  log.info("User store initialized.");
}

/**
 * Creates a new user and returns their token. Optionally accepts parameters
 * for setting an expiry date and/or token limits for temporary users.
 **/
export function createUser(createOptions?: {
  type?: User["type"];
  expiresAt?: number;
  tokenLimits?: User["tokenLimits"];
}) {
  const token = uuid();
  const newUser: User = {
    token,
    ip: [],
    type: "normal",
    promptCount: 0,
    tokenCounts: { ...INITIAL_TOKENS },
    tokenLimits: createOptions?.tokenLimits ?? { ...config.tokenQuota },
    createdAt: Date.now(),
  };

  if (createOptions?.type === "temporary") {
    Object.assign(newUser, {
      type: "temporary",
      expiresAt: createOptions.expiresAt,
    });
  } else {
    Object.assign(newUser, { type: createOptions?.type ?? "normal" });
  }

  users.set(token, newUser);
  usersToFlush.add(token);
  return token;
}

/** Returns the user with the given token if they exist. */
export function getUser(token: string) {
  return users.get(token);
}

/** Returns a list of all users. */
export function getUsers() {
  return Array.from(users.values()).map((user) => ({ ...user }));
}

/**
 * Upserts the given user. Intended for use with the /admin API for updating
 * arbitrary fields on a user; use the other functions in this module for
 * specific use cases. `undefined` values are left unchanged. `null` will delete
 * the property from the user.
 *
 * Returns the upserted user.
 */
export function upsertUser(user: UserUpdate) {
  const existing: User = users.get(user.token) ?? {
    token: user.token,
    ip: [],
    type: "normal",
    promptCount: 0,
    tokenCounts: { ...INITIAL_TOKENS },
    tokenLimits: { ...config.tokenQuota },
    createdAt: Date.now(),
  };

  const updates: Partial<User> = {};

  for (const field of Object.entries(user)) {
    const [key, value] = field as [keyof User, any]; // already validated by zod
    if (value === undefined || key === "token") continue;
    if (value === null) {
      delete existing[key];
    } else {
      updates[key] = value;
    }
  }

  // TODO: Write firebase migration to backfill new fields
  if (updates.tokenCounts) {
    for (const family of MODEL_FAMILIES) {
      updates.tokenCounts[family] ??= 0;
    }
  }
  if (updates.tokenLimits) {
    for (const family of MODEL_FAMILIES) {
      updates.tokenLimits[family] ??= 0;
    }
  }

  users.set(user.token, Object.assign(existing, updates));
  usersToFlush.add(user.token);

  // Immediately schedule a flush to the database if we're using Firebase.
  if (config.gatekeeperStore === "firebase_rtdb") {
    setImmediate(flushUsers);
  }

  return users.get(user.token);
}

/** Increments the prompt count for the given user. */
export function incrementPromptCount(token: string) {
  const user = users.get(token);
  if (!user) return;
  user.promptCount++;
  usersToFlush.add(token);
}

/** Increments token consumption for the given user and model. */
export function incrementTokenCount(
  token: string,
  model: string,
  api: APIFormat,
  consumption: number
) {
  const user = users.get(token);
  if (!user) return;
  const modelFamily = getModelFamilyForQuotaUsage(model, api);
  const existing = user.tokenCounts[modelFamily] ?? 0;
  user.tokenCounts[modelFamily] = existing + consumption;
  usersToFlush.add(token);
}

/**
 * Given a user's token and IP address, authenticates the user and adds the IP
 * to the user's list of IPs. Returns the user if they exist and are not
 * disabled, otherwise returns undefined.
 */
export function authenticate(
  token: string,
  ip: string
): { user?: User; result: "success" | "disabled" | "not_found" | "limited" } {
  const user = users.get(token);
  if (!user) return { result: "not_found" };
  if (user.disabledAt) return { result: "disabled" };

  const newIp = !user.ip.includes(ip);

  const userLimit = user.maxIps ?? config.maxIpsPerUser;
  const enforcedLimit =
    user.type === "special" || !userLimit ? Infinity : userLimit;

  if (newIp && user.ip.length >= enforcedLimit) {
    if (config.maxIpsAutoBan) {
      user.ip.push(ip);
      disableUser(token, "IP address limit exceeded.");
      return { result: "disabled" };
    }
    return { result: "limited" };
  } else if (newIp) {
    user.ip.push(ip);
  }

  user.lastUsedAt = Date.now();
  usersToFlush.add(token);
  return { user, result: "success" };
}

export function hasAvailableQuota({
  userToken,
  model,
  api,
  requested,
}: {
  userToken: string;
  model: string;
  api: APIFormat;
  requested: number;
}) {
  const user = users.get(userToken);
  if (!user) return false;
  if (user.type === "special") return true;

  const modelFamily = getModelFamilyForQuotaUsage(model, api);
  const { tokenCounts, tokenLimits } = user;
  const tokenLimit = tokenLimits[modelFamily];

  if (!tokenLimit) return true;

  const tokensConsumed = (tokenCounts[modelFamily] ?? 0) + requested;
  return tokensConsumed < tokenLimit;
}

export function refreshQuota(token: string) {
  const user = users.get(token);
  if (!user) return;
  const { tokenCounts, tokenLimits } = user;
  const quotas = Object.entries(config.tokenQuota) as [ModelFamily, number][];
  quotas
    // If a quota is not configured, don't touch any existing limits a user may
    // already have been assigned manually.
    .filter(([, quota]) => quota > 0)
    .forEach(
      ([model, quota]) =>
        (tokenLimits[model] = (tokenCounts[model] ?? 0) + quota)
    );
  usersToFlush.add(token);
}

export function resetUsage(token: string) {
  const user = users.get(token);
  if (!user) return;
  const { tokenCounts } = user;
  const counts = Object.entries(tokenCounts) as [ModelFamily, number][];
  counts.forEach(([model]) => (tokenCounts[model] = 0));
  usersToFlush.add(token);
}

/** Disables the given user, optionally providing a reason. */
export function disableUser(token: string, reason?: string) {
  const user = users.get(token);
  if (!user) return;
  user.disabledAt = Date.now();
  user.disabledReason = reason;
  usersToFlush.add(token);
}

export function getNextQuotaRefresh() {
  if (!quotaRefreshJob) return "never (manual refresh only)";
  return quotaRefreshJob.nextInvocation().getTime();
}

/**
 * Cleans up expired temporary tokens by disabling tokens past their access
 * expiry date and permanently deleting tokens three days after their access
 * expiry date.
 */
function cleanupExpiredTokens() {
  const now = Date.now();
  let disabled = 0;
  let deleted = 0;
  for (const user of users.values()) {
    if (user.type !== "temporary") continue;
    if (user.expiresAt && user.expiresAt < now && !user.disabledAt) {
      disableUser(user.token, "Temporary token expired.");
      disabled++;
    }
    if (user.disabledAt && user.disabledAt + 72 * 60 * 60 * 1000 < now) {
      users.delete(user.token);
      usersToFlush.add(user.token);
      deleted++;
    }
  }
  log.trace({ disabled, deleted }, "Expired tokens cleaned up.");
}

function refreshAllQuotas() {
  let count = 0;
  for (const user of users.values()) {
    if (user.type === "temporary") continue;
    refreshQuota(user.token);
    count++;
  }
  log.info(
    { refreshed: count, nextRefresh: quotaRefreshJob!.nextInvocation() },
    "Token quotas refreshed."
  );
}

// TODO: Firebase persistence is pretend right now and just polls the in-memory
// store to sync it with Firebase when it changes. Will refactor to abstract
// persistence layer later so we can support multiple stores.
let firebaseTimeout: NodeJS.Timeout | undefined;

async function initFirebase() {
  log.info("Connecting to Firebase...");
  const app = getFirebaseApp();
  const db = admin.database(app);
  const usersRef = db.ref("users");
  const snapshot = await usersRef.once("value");
  const users: Record<string, User> | null = snapshot.val();
  firebaseTimeout = setInterval(flushUsers, 20 * 1000);
  if (!users) {
    log.info("No users found in Firebase.");
    return;
  }
  for (const token in users) {
    upsertUser(users[token]);
  }
  usersToFlush.clear();
  const numUsers = Object.keys(users).length;
  log.info({ users: numUsers }, "Loaded users from Firebase");
}

async function flushUsers() {
  const app = getFirebaseApp();
  const db = admin.database(app);
  const usersRef = db.ref("users");
  const updates: Record<string, User> = {};
  const deletions = [];

  for (const token of usersToFlush) {
    const user = users.get(token);
    if (!user) {
      deletions.push(token);
      continue;
    }
    updates[token] = user;
  }

  usersToFlush.clear();

  const numUpdates = Object.keys(updates).length + deletions.length;
  if (numUpdates === 0) {
    return;
  }

  await usersRef.update(updates);
  await Promise.all(deletions.map((token) => usersRef.child(token).remove()));
  log.info(
    { users: Object.keys(updates).length, deletions: deletions.length },
    "Flushed changes to Firebase"
  );
}

function getModelFamilyForQuotaUsage(
  model: string,
  api: APIFormat
): ModelFamily {
  switch (api) {
    case "openai":
    case "openai-text":
    case "openai-image":
      return getOpenAIModelFamily(model);
    case "anthropic":
      return getClaudeModelFamily(model);
    case "google-palm":
      return getGooglePalmModelFamily(model);
    default:
      assertNever(api);
  }
}

function getRefreshCrontab() {
  switch (config.quotaRefreshPeriod!) {
    case "hourly":
      return "0 * * * *";
    case "daily":
      return "0 0 * * *";
    default:
      return config.quotaRefreshPeriod ?? "0 0 * * *";
  }
}


code:
import { Query } from "express-serve-static-core";
import sanitize from "sanitize-html";
import { z } from "zod";

export function parseSort(sort: Query["sort"]) {
  if (!sort) return null;
  if (typeof sort === "string") return sort.split(",");
  if (Array.isArray(sort)) return sort.splice(3) as string[];
  return null;
}

export function sortBy(fields: string[], asc = true) {
  return (a: any, b: any) => {
    for (const field of fields) {
      if (a[field] !== b[field]) {
        // always sort nulls to the end
        if (a[field] == null) return 1;
        if (b[field] == null) return -1;

        const valA = Array.isArray(a[field]) ? a[field].length : a[field];
        const valB = Array.isArray(b[field]) ? b[field].length : b[field];

        const result = valA < valB ? -1 : 1;
        return asc ? result : -result;
      }
    }
    return 0;
  };
}

export function paginate(set: unknown[], page: number, pageSize: number = 20) {
  const p = Math.max(1, Math.min(page, Math.ceil(set.length / pageSize)));
  return {
    page: p,
    items: set.slice((p - 1) * pageSize, p * pageSize),
    pageSize,
    pageCount: Math.ceil(set.length / pageSize),
    totalCount: set.length,
    nextPage: p * pageSize < set.length ? p + 1 : null,
    prevPage: p > 1 ? p - 1 : null,
  };
}

export function sanitizeAndTrim(
  input?: string | null,
  options: sanitize.IOptions = {
    allowedTags: [],
    allowedAttributes: {},
  }
) {
  return sanitize((input ?? "").trim(), options);
}

// https://github.com/colinhacks/zod/discussions/2050#discussioncomment-5018870
export function makeOptionalPropsNullable<Schema extends z.AnyZodObject>(
  schema: Schema
) {
  const entries = Object.entries(schema.shape) as [
    keyof Schema["shape"],
    z.ZodTypeAny
  ][];
  const newProps = entries.reduce(
    (acc, [key, value]) => {
      acc[key] =
        value instanceof z.ZodOptional ? value.unwrap().nullable() : value;
      return acc;
    },
    {} as {
      [key in keyof Schema["shape"]]: Schema["shape"][key] extends z.ZodOptional<
        infer T
      >
        ? z.ZodNullable<T>
        : Schema["shape"][key];
    }
  );
  return z.object(newProps);
}

export function redactIp(ip: string) {
  const ipv6 = ip.includes(":");
  return ipv6 ? "redacted:ipv6" : ip.replace(/\.\d+\.\d+$/, ".xxx.xxx");
}

export function assertNever(x: never): never {
  throw new Error(`Called assertNever with argument ${x}.`);
}


code:
<% if (flashData) {
  let flashStyle = { title: "", style: "" };
  switch (flashData.type) {
    case "success":
      flashStyle.title = "✅ Success:";
      flashStyle.style = "color: green; background-color: #ddffee; padding: 1em";
      break;
    case "error":
      flashStyle.title = "⚠ Error:";
      flashStyle.style = "color: red; background-color: #eedddd; padding: 1em";
      break;
    case "warning":
      flashStyle.title = "⚠ Alert:";
      flashStyle.style = "color: darkorange; background-color: #ffeecc; padding: 1em";
      break;
    case "info":
      flashStyle.title = "ℹ Notice:";
      flashStyle.style = "color: blue; background-color: #ddeeff; padding: 1em";
      break;
  }
%>
<p style="<%= flashStyle.style %>">
  <%= flashStyle.title %> <%= flashData.message %>

<% } %>


code:
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="csrf-token" content="<%= csrfToken %>">
    <title><%= title %></title>
    <style>
      a:hover {
        background-color: #e0e6f6;
      }

      a:visited:hover {
        background-color: #e7e0f6;
      }

      .pagination {
        list-style-type: none;
        padding: 0;
      }
      .pagination li {
        display: inline-block;
      }
      .pagination li a {
        display: block;
        padding: 0.5em 1em;
        text-decoration: none;
      }
      .pagination li.active a {
        background-color: #58739c;
        color: #fff;
      }

      table {
        border-collapse: collapse;
        border: 1px solid #ccc;
      }
      table.striped tr:nth-child(even) {
        background-color: #eaeaea
      }
      table td, table th {
        border: 1px solid #ccc;
        padding: 0.25em 0.5em;
      }
      th.active {
        background-color: #e0e6f6;
      }
      td.actions {
        padding: 0;
        width: 0;
        text-align: center;
      }
      td.actions a {
        text-decoration: none;
        background-color: transparent;
        padding: 0.5em;
        height: 100%;
        width: 100%;
      }
      td.actions:hover {
        background-color: #e0e6f6;
      }

      @media (max-width: 600px) {
        table {
          width: 100%;
        }
        table td, table th {
          display: block;
          width: 100%;
        }
      }
    </style>
  </head>
  <body style="font-family: sans-serif; background-color: #f0f0f0; padding: 1em;">
    <%- include("partials/shared_flash", { flashData: flash }) %>


code:
  Page Size
  <select id="pageSize" onchange="setPageSize(this.value)" style="margin-bottom: 1rem;">
    <option value="10" <% if (pageSize === 10) { %>selected<% } %>>10</option>
    <option value="20" <% if (pageSize === 20) { %>selected<% } %>>20</option>
    <option value="50" <% if (pageSize === 50) { %>selected<% } %>>50</option>
    <option value="100" <% if (pageSize === 100) { %>selected<% } %>>100</option>
    <option value="200" <% if (pageSize === 200) { %>selected<% } %>>200</option>
  </select>


code:
Next refresh: <time><%- nextQuotaRefresh %></time>

      Model Family
      Usage
      <% if (showTokenCosts) { %>
      Cost
      <% } %>
      Limit
      Remaining
      Refresh Amount

    <% Object.entries(quota).forEach(([key, limit]) => { %>

      <%- key %>
      <%- prettyTokens(user.tokenCounts[key]) %>
      <% if (showTokenCosts) { %>
      $<%- tokenCost(key, user.tokenCounts[key]).toFixed(2) %>
      <% } %>
      <% if (!user.tokenLimits[key]) { %>
      unlimited
      <% } else { %>
      <%- prettyTokens(user.tokenLimits[key]) %>
      <%- prettyTokens(user.tokenLimits[key] - user.tokenCounts[key]) %>
      <% } %>
      <% if (user.type === "temporary") { %>
      N/A
      <% } else { %>
      <%- prettyTokens(quota[key]) %>
      <% } %>

    <% }) %>


code:
Show all (<%- user.ip.length %>)

  <% user.ip.forEach((ip) => { %>

<%- shouldRedact ? redactIp(ip) : ip %>

  <% }) %>


code:
import cookieParser from "cookie-parser";
import expressSession from "express-session";
import MemoryStore from "memorystore";
import { config, COOKIE_SECRET } from "../config";

const ONE_WEEK = 1000 * 60 * 60 * 24 * 7;

const cookieParserMiddleware = cookieParser(COOKIE_SECRET);

const sessionMiddleware = expressSession({
  secret: COOKIE_SECRET,
  resave: false,
  saveUninitialized: false,
  store: new (MemoryStore(expressSession))({ checkPeriod: ONE_WEEK }),
  cookie: {
    sameSite: "strict",
    maxAge: ONE_WEEK,
    signed: true,
    secure: !config.useInsecureCookies,
  },
});

const withSession = [cookieParserMiddleware, sessionMiddleware];

export { withSession };


code:
import type { HttpRequest } from "@smithy/types";
import { Express } from "express-serve-static-core";
import { APIFormat, Key, LLMService } from "../shared/key-management";
import { User } from "../shared/users/schema";

declare global {
  namespace Express {
    interface Request {
      key?: Key;
      service?: LLMService;
      /** Denotes the format of the user's submitted request. */
      inboundApi: APIFormat;
      /** Denotes the format of the request being proxied to the API. */
      outboundApi: APIFormat;
      /** If the request comes from a RisuAI.xyz user, this is their token. */
      risuToken?: string;
      user?: User;
      isStreaming?: boolean;
      startTime: number;
      retryCount: number;
      queueOutTime?: number;
      onAborted?: () => void;
      proceed: () => void;
      heartbeatInterval?: NodeJS.Timeout;
      monitorInterval?: NodeJS.Timeout;
      promptTokens?: number;
      outputTokens?: number;
      tokenizerInfo: Record<string, any>;
      signedRequest: HttpRequest;
    }
  }
}

declare module "express-session" {
  interface SessionData {
    adminToken?: string;
    userToken?: string;
    csrf?: string;
    flash?: { type: string; message: string };
  }
}


code:
import express, { Router } from "express";
import { injectCsrfToken, checkCsrfToken } from "../shared/inject-csrf";
import { selfServiceRouter } from "./web/self-service";
import { injectLocals } from "../shared/inject-locals";
import { withSession } from "../shared/with-session";

const userRouter = Router();

userRouter.use(
  express.json({ limit: "1mb" }),
  express.urlencoded({ extended: true, limit: "1mb" })
);
userRouter.use(withSession);
userRouter.use(injectCsrfToken, checkCsrfToken);
userRouter.use(injectLocals);

userRouter.use(selfServiceRouter);

userRouter.use(
  (
    err: Error,
    _req: express.Request,
    res: express.Response,
    _next: express.NextFunction
  ) => {
    const data: any = { message: err.message, stack: err.stack, status: 500 };
    res.status(500).render("user_error", { ...data, flash: null });
  }
);

export { userRouter };


code:
import { Router } from "express";
import { UserPartialSchema } from "../../shared/users/schema";
import * as userStore from "../../shared/users/user-store";
import { ForbiddenError, UserInputError } from "../../shared/errors";
import { sanitizeAndTrim } from "../../shared/utils";
import { config } from "../../config";

const router = Router();

router.use((req, res, next) => {
  if (req.session.userToken) {
    res.locals.currentSelfServiceUser =
      userStore.getUser(req.session.userToken) || null;
  }
  next();
});

router.get("/", (_req, res) => {
  res.redirect("/");
});

router.get("/lookup", (_req, res) => {
  const ipLimit =
    (res.locals.currentSelfServiceUser?.maxIps ?? config.maxIpsPerUser) || 0;
  res.render("user_lookup", {
    user: res.locals.currentSelfServiceUser,
    ipLimit,
  });
});

router.post("/lookup", (req, res) => {
  const token = req.body.token;
  const user = userStore.getUser(token);
  req.log.info(
    { token: truncateToken(token), success: !!user },
    "User self-service lookup"
  );
  if (!user) {
    req.session.flash = { type: "error", message: "Invalid user token." };
    return res.redirect("/user/lookup");
  }
  req.session.userToken = user.token;
  return res.redirect("/user/lookup");
});

router.post("/edit-nickname", (req, res) => {
  const existing = res.locals.currentSelfServiceUser;

  if (!existing) {
    throw new ForbiddenError("Not logged in.");
  } else if (!config.allowNicknameChanges || existing.disabledAt) {
    throw new ForbiddenError("Nickname changes are not allowed.");
  } else if (!config.maxIpsAutoBan && !existing.ip.includes(req.ip)) {
    throw new ForbiddenError(
      "Nickname changes are only allowed from registered IPs."
    );
  }

  const schema = UserPartialSchema.pick({ nickname: true })
    .strict()
    .transform((v) => ({ nickname: sanitizeAndTrim(v.nickname) }));

  const result = schema.safeParse(req.body);
  if (!result.success) {
    throw new UserInputError(result.error.message);
  }

  const newNickname = result.data.nickname || null;
  userStore.upsertUser({ token: existing.token, nickname: newNickname });
  req.session.flash = { type: "success", message: "Nickname updated." };
  return res.redirect("/user/lookup");
});

function truncateToken(token: string) {
  const sliceLength = Math.max(Math.floor(token.length / 8), 1);
  return `${token.slice(0, sliceLength)}...${token.slice(-sliceLength)}`;
}

export { router as selfServiceRouter };


code:
<%- include("partials/shared_header", { title: "Error" }) %>

⚠ Error <%= status %>: <%= message %>

<%= stack %>

      Go Back


code:
<!DOCTYPE html>

  <meta charset="UTF-8">
  <meta name="robots" content="noindex" />
  <title><%= title %></title>

    <%= pageHeader %>

Service Info

<%= JSON.stringify(serviceInfo, null, 2) %>


code:
<%- include("partials/shared_header", { title: "User Token Lookup" }) %>

User Token Lookup

Provide your user token to check your usage and quota information.

<form action="/user/lookup" method="post">
  <input type="hidden" name="_csrf" value="<%= csrfToken %>" />
  User Token
  <input type="password" name="token" value="<%= user?.token %>" />
  <input type="submit" value="Lookup" />
</form>
<% if (user) { %>

<% if (user.type === "temporary" && Boolean(user.disabledAt)) { %>
<%- include("partials/shared_flash", { flashData: {
      type: "info",
      message: "This temporary user token has expired and is no longer usable. These records will be deleted soon.",
    } }) %>
<% } else if (user.disabledAt) { %>
<%- include("partials/shared_flash", { flashData: {
      type: "warning",
      message: "This user token has been disabled." + (user.disabledReason ? ` Reason: ${user.disabledReason}` : ""),
    } }) %>
<% } %>

      User Token
       <%- "..." + user.token.slice(-5) %>

      Nickname
      <%- user.nickname ?? "none" %>

        ✏

      Type
      <%- user.type %>

      Prompts
      <%- user.promptCount %>

      Created At
      <%- user.createdAt %>

      Last Used At
      <%- user.lastUsedAt || "never" %>

      IPs<%- ipLimit ? ` (max ${ipLimit})` : "" %>
      <%- include("partials/shared_user_ip_list", { user, shouldRedact: true }) %>

    <% if (user.type === "temporary") { %>

      Expires At
      <%- user.expiresAt %>

    <% } %>

Quota Information

<%- include("partials/shared_quota-info", { quota, user }) %>

<form id="edit-nickname-form" style="display: none" action="/user/edit-nickname" method="post">
  <input type="hidden" name="_csrf" value="<%= csrfToken %>" />
  <input type="hidden" name="nickname" value="<%= user.nickname %>" />
</form>

<% } %> <%- include("partials/user_footer") %>


code:
node_modules/
backup.sh
configs/*.json
configs/*.js
static/*
gulp/res/js/socket.io.js
views/custompages/*.pug
/gulp/res/css/codethemes
gulp/res/css/locals.css
gulp/res/css/custom.css
gulp/res/js/locals.js
gulp/res/js/post.js
gulp/res/js/modal.js
tmp/
.idea/

repo_name: dolphin/jschan

path: .gitignore

license: mit


code:
# jschan
Anonymous imageboard software.

Demo/test site: [clearnet](https://fatchan.org) [tor hidden service](http://cimixezweeq64g42vl6tyhk4becxhi4ldwqq6w43u53qhwsd3s4c3lyd.onion/)

## Goals
- Oldschool look, newschool features
- Work with javascript disabled
- Support running as a hidden service
- Be usable on mobile
- Simple static file serving

## Features
- [x] User created boards ala infinity
- [x] Multiple files per post
- [x] Basic antispam & multiple captcha options
- [x] Read-only JSON api
- [x] Multi-select moderation actions
- [x] Websocket update threads w/o polling
- [x] Webring w/proxy support (compatible with [lynxchan](https://gitlab.com/alogware/LynxChanAddon-Webring) & [infinity](https://gitlab.com/Tenicu/infinityaddon-webring))
- [x] Run as a tor hidden service

## Todo
- More features
- Mobile app integration
- Improve installation instructions

## Setup
##### Please note:
#### 🚨 jschan is not production-ready. There may be bugs and WILL be breaking changes. 🚨
##### If you insist on running your own instance, do not expect help when something breaks. Please read everything including the update section thoroughly. These instructions are NOT step-by-step or complete, and assume you have some experience with software setup, servers, networking, etc.

##### Requirements
- Linux - Debian used in this example
- Node.js - the application runtime
- MongoDB - the database
- Redis - session store, task queue, locks, caching, websocket message arbiter
- Nginx - webserver/proxy, serve static files, handle https, GeoIP lookup
- Certbot/letsencrypt - to get a free https certificate
- Graphicsmagick+Imagemagick - identify and thumbnail images, generate captchas
- Ffmpeg - identify and thumbnail audio and video

-----

**1. Setup server with some basics**

- Separate, non-root user to run the application
- Basic security like ssh root login disabled, key login only, firewall all ports besides http/s and ssh.

**2. Install dependencies.**

NOTE: You may need to add sources depending on your distro.
```bash
$ sudo apt-get update
$ sudo apt-get install nginx ffmpeg imagemagick graphicsmagick
```

**3. Install MongoDB**

[MongoDB Installation](https://docs.mongodb.com/manual/tutorial/install-mongodb-on-debian/#install-mongodb-community-edition-on-debian) & [enable authentication](https://medium.com/mongoaudit/how-to-enable-authentication-on-mongodb-b9e8a924efac)

**4. Install Redis**

[Redis Installation](https://www.digitalocean.com/community/tutorials/how-to-install-and-secure-redis-on-debian-9)

**5. Install Node.js**

For easy installation, use [node version manager](https://github.com/nvm-sh/nvm) "nvm".
Install nvm then run the following commands to get the LTS version of nodejs.
```bash
$ nvm install --lts
$ nvm use --lts
```
You can repeat this process in future to update node versions.

You may install Node.js yourself without nvm if you prefer.

**6. Configure nginx**

- Copy the nginx.example config to the sites-eavailable folder, and create a symlink from sites-available -> sites-enabled
```bash
$ sudo cp configs/nginx/nginx.example /etc/nginx/sites-available/DOMAIN.COM
$ sudo ln -s /etc/nginx/sites-available/DOMAIN.COM /etc/nginx/sites-enabled/DOMAIN.COM
```

Edit/replace the following in your nginx config:
- "domain.com" with your domain name
- "/path/to/jschan" with the path of your jschan root folder
- If using .onion, uncomment the .onion block, and replace the example address with your .onion
```bash
$ sudo editor /etc/nginx/sites-available/DOMAIN.COM
```

- Make sure the sites enabled folder is included by `/etc/nginx/nginx.conf` (it is in debian nginx package)
- Use [certbot](https://certbot.eff.org/) to get a free https certificate.
- For post flags to work, [follow this guide](http://archive.is/2SMOb) to setup the [legacy GeoIP database](https://www.miyuru.lk/geoiplegacy) and add these directives to the http block in `/etc/nginx/nginx.conf`:
```
geoip_country /usr/share/GeoIP/GeoIP.dat;
geoip_city /usr/share/GeoIP/GeoIPCity.dat;
```
If your nginx doesn't have the necessary module by default, or is using v2 instead, find your own guide.

If you use cloudflare, please read [these](https://support.cloudflare.com/hc/en-us/articles/200170786-Restoring-original-visitor-IPs-Logging-visitor-IP-addresses-with-mod-cloudflare-) [articles](https://support.cloudflare.com/hc/en-us/articles/200168236-Configuring-Cloudflare-IP-Geolocation) to setup proper IP forwarding and geolocation headers. Similar steps would apply to other CDNs/reverse proxies.
There are also 2 config entries in configs/main.js, `ipHeader` and `countryCodeHeader` that you can tweak for jschan to use different headers for country code or real visitor IP.

Also included is an "nginx_advanced" config, and a snippets folder for advanced users who want to better organise and more easily customise the nginx configuration. It functions the same as the normal nginx.example, but you need to create the snippets folder in /etc/nginx/snippets, and copy the example snippets.

**7. Clone this repo, browse to the folder and set some things up**

```bash
# copy example config file and edit it
$ cp configs/main.js.example configs/main.js && editor configs/main.js

# copy example custompages for rules and faq and edit
$ cp views/custompages/faq.pug.example views/custompages/faq.pug
$ cp views/custompages/rules.pug.example views/custompages/rules.pug

# install dependencies and run build tasks
$ npm install
$ npm run-script setup

# setup the database and folder structure, and creates the admin account. **The (random) password will be printed in the command line.**
$ gulp reset
# NOTE: dont run gulp reset again unless you want to completely irreversibly wipe everything

# make pm2 (process manager) start on server restart
$ pm2 startup #and follow any prompts
# save the process list so jschan is started with pm2
$ pm2 save
```

Use https://realfavicongenerator.net/ to generate favicons how you want them to look and replace the files in gulp/res/icons/ with the icons from that package

```bash
# start all the backend processes
$ npm run-script start
$ gulp

# some commands you may need to use in future/find helpful
# pm2 is a process manager for nodejs
$ pm2 list #list running pm2 processes
$ pm2 logs #see logs
$ pm2 reload all #reload all backend processes

# gulp is used for various jobs like minifying and compiling scripts
$ gulp --tasks #list available gulp tasks
$ gulp migrate #check for and run db migrations
$ gulp #run default gulp task
```

**8. Optionally, if you plan to use the webring and want to make requests with a proxy to mask your origin server IP:**

EITHER:

- Install docker and run torproxy in a container: https://github.com/dperson/torproxy (of course, audit the docker image yourself). This is the easiest, free way to get a proxy setup and means you can also follow .onions in your webring list since requests will go through tor.
- Use your own socks proxy

Edit configs/webring.json with your proxy address and set enabled: true

## Updating

```bash
#pull the latest changes
$ git pull
#install dependencies again in case any have updated or changed
$ npm install
#diff the config files to see if anything changed and edit accordingly. OR backup your config, replace it with the fresh example, and update it with whatever settings you want to keep from your backup.
$ diff configs/main.js configs/main.js.example
#run the migrate task to update your database
$ gulp migrate
#reload jschan backend
$ pm2 reload all
#run the default gulp task, updates scripts, css, icons, images and deletes old html
$ gulp
#if something breaks, check and read the logs, they will help figure out what went wrong.
$ pm2 logs
```

## For generous people

BTC address: `bc1q4elrlz5puak4m9xy3hfvmpempnpqpu95v8s9m6`


code:
#!/bin/bash

#change these
APP_NAME=""
MONGO_DATABASE="jschan"
MONGO_HOST=""
MONGO_PORT=""
MONGO_USER=""
MONGO_PASSWORD=""
TIMESTAMP=`date +%F-%H%M`
BACKUPS_DIR="/path/to/$APP_NAME"

#probably dont change these
DB_BACKUP_NAME="$APP_NAME-$TIMESTAMP.gz"
FILE_BACKUP_NAME="$APP_NAME-$TIMESTAMP-files.zip"
DB_ARCHIVE_PATH="$BACKUPS_DIR/$DB_BACKUP_NAME"
FILE_ARCHIVE_PATH="$BACKUPS_DIR/$FILE_BACKUP_NAME"

#make folder
mkdir -p $BACKUPS_DIR

#backups files
zip -r -0 $FILE_ARCHIVE_PATH ./static

#backup db
mongodump --username $MONGO_USER --password $MONGO_PASSWORD --authenticationDatabase admin --db $MONGO_DATABASE --archive=$DB_ARCHIVE_PATH --gzip
rm -rf dump

#delete older than 7 days
sudo find $ARCHIVE_PATH -type f -name "*.gz" -mtime +7 -exec rm -f {} \;

exit 0


code:
module.exports = {

	//mongodb connection string
	dbURL: 'mongodb://username:password@localhost:27017',
	dbName: 'jschan',

	//redis connection info
	redis: {
		host: '127.0.0.1',
		port: '6379',
		password: 'long random string'
	},

	//backend webserver port
	port: 7000,

	//secrets/salts for various things
	cookieSecret: 'long random string',
	tripcodeSecret: 'long random string',
	ipHashSecret: 'long random string',
	postPasswordSecret: 'long random string',

	//enable secure cookies, only use with https/not tor
	secureCookies: true,

	//check referrer to prevent some CSRF attack
	refererCheck: true,
	//list of allowed host for checking referrer
	allowedHosts: ['domain.com', 'www.domain.com'],

	//header for country codes
	countryCodeHeader: 'x-country-code',
	//header for visitor IP
	ipHeader: 'x-real-ip',

	//data used in opengraph meta tags. used to generate link previews in e.g. discord, twitter, etc
	meta: {
		siteName: 'imageboard',
		url: 'https://domain.com'
	},

	//settings for captchas
	captchaOptions: {
		type: 'grid', //"text", "grid", "hcaptcha" or "google". If using google/hcaptcha, make sure your CSP header in nginx config allows the google/hcaptcha domain.
		generateLimit: 1000, //max number of captchas to have generated at any time, prevent mass unsolved captcha spam, especially on TOR.
		google: { //options for google captcha, when captcha type is google
			siteKey: 'zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz',
			secretKey: 'zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz'
		},
        hcaptcha: {
            siteKey: "10000000-ffff-ffff-ffff-000000000001",
            secretKey: "0x0000000000000000000000000000000000000000"
        },
		grid: {
			size: 4,
			imageSize: 120,
			iconYOffset: 15,
		},
		numDistorts: {
			min: 3,
			max: 4
		},
		distortion: 9,
	},

	/* dnsbl, will add a small delay for uncached requests. You could also install some
		kind of dns cache e.g. unbound to improve performance. DNSBL only checked for posting */
	dnsbl: {
		enabled: false,
		blacklists: ['tor.dan.me.uk', 'zen.spamhaus.org'],
		cacheTime: 3600 //in seconds, idk whats a good value
	},

	//disable file posting over .onion globally, overrides any board setting.
	disableOnionFilePosting: false,

	floodTimers: { //basic delays to stop flooding, in ms. 0 to disable
		sameContentSameIp: 120000, //same message or any file from same ip
		sameContentAnyIp: 30000, //same message or file from any ip
		anyContentSameIp: 5000, //any post from the same ip
	},

	//block bypasses
	blockBypass: {
		enabled: false,
		expireAfterUses: 40, //however many (attempted) posts per block bypass captcha
		expireAfterTime: 86400000, //expiry in ms regardless if the limit was reached, default 1 day
		bypassDnsbl: false,
	},

	// permission level required to see UNHASHED ips. -1 for ips to be hashed even for root user. not recommended to change after installation
	ipHashPermLevel: 0,

	/* delete files immediately rather than pruning later. usually disabled to prevent re-thumbnailing and processing commonly
		uploaded files, but deleting immediately is better if you are concerned about "deleted" content not being immediately removed */
	pruneImmediately: false,

	rateLimitCost: { //Cost out of 100 per minute e.g. cost of 25 means 4 per minute. Cost is separate for each.
		captcha: 10,
		boardSettings: 30,
		editPost: 30,
	},

	//how many threads to show on overboard
	overboardLimit: 20,

	//cache templates in memory. disable only if editing templates and doing dev work
	cacheTemplates: true,

	//log extra info for debugging
	debugLogs: true,

	//max wait time in ms for obtaining locks for saving files
	lockWait: 3000,

	//optionally prune oldest modlog entries (prunes when newer modlog entries are generated i.e. dead boards wont have older logs pruned)
	pruneModlogs: true,
	pruneAfterDays: 30,

	//enable the webring (also copy configs/webring.json.example -> configs/webring.json and edit)
	enableWebring: false,

	//let all users create new boards
	enableUserBoardCreation: true,
	//let all users register new accounts
	enableUserAccountCreation: true,

	//extension for thumbnails that do not contain transparency (png will be used)
	thumbExtension: '.jpg',
	//.gif images > thumbnail size will have animated .gif thumbnails, overriding thumbExtension
	animatedGifThumbnails: false,
	//if animatedGifThumbnails is true, use ffmpeg for better animated gif thumbnailing. RECOMMENDED
	ffmpegGifThumbnails: true,

	//generate waveform thumbnails for audio
	audioThumbnails: false,

	//max thumb dimensions (square) in px
	thumbSize: 250,

	//0-100 percent through a video to try taking video thumbnails from, to remedy black first frames or fade-ins
	videoThumbPercentage: 5,

	/* extra mime types for files to be uploaded as attachments (no thumbnails) e.g. text files/archives
		NOTE: appropriate extensions will need to be added to nginx configuration, and uncomment the provided
		"other files" section which includes an example configuration for .txt files to match this default config.
		mime types and file extension does not always correspond exactly this cant be done automatically. */
	otherMimeTypes: [
		'text/plain',
		'application/pdf'
	],

	//check the real mime type of uploaded files
	checkRealMimeTypes: false,
	//if checking real mime types, and the real type is unknown, allow it anyway
	allowMimeNoMatch: false,

	//default ban duration in ms if ban duration field is left blank (default value is 1 year)
	defaultBanDuration: 31536000000,

	//max number of quotes that will be linked in a post. 0 for unlimited (not recommended)
	quoteLimit: 25,

	//apply global filters more aggressively, trying against extra text that strips diacritics and some ZWS chars
	strictFiltering: false,

	//how many replies to show on index pages under each OP
	previewReplies: 5,
	stickyPreviewReplies: 5, //choose a different amount for sticky posts if desired

	/* the fraction of threadLimit beyond which early404 prunes posts
		e.g. 3 means after the first third, so if you had 6 pages,
		anything after page 2 with less than early404Replies gets pruned */
	early404Fraction: 3,
	//how many replies a thread needs to not get removed by early404
	early404Replies: 5,

	//how many of the most recent newsposts to show on the homepage
	maxRecentNews: 3,

	/* filter filenames on posts and banners
		false=no filtering
		true=allow only A-Za-z0-9_-
		regex=custom regex of what to replace e.g. /[^\w\s-]+/g */
	filterFileNames: false,
	/* replace spaces and multiple spaces with some character default _ for better filenames
		(spaces dont belong in filenames) */
	spaceFileNameReplacement: '_',

	//options for code block highlighting in posts
	highlightOptions: {

		//subset of languages to allow.
		languageSubset: [
			'javascript',
			'typescript',
			'perl',
			'js',
			'c++',
			'c',
			'java',
			'kotlin',
			'php',
			'h',
			'csharp',
			'bash',
			'sh',
			'zsh',
			'python',
			'ruby',
			'css',
			'html',
			'json',
			'golang',
			'rust'
		],

		//threshold below which auto language is ignored
		threshold: 5

	},

	//uses names of css in gulp/res/css. if blank, all themes are enabled.
	themes: [],
	codeThemes: [],

	//global limits for board settings
	globalLimits:  {
		threadLimit: { //number of threads, 10 per page
			min: 10,
			max: 200
		},
		replyLimit: { //number of replies to a thread, thread is locked after this limit is reached
			min: 10,
			max: 500
		},
		bumpLimit: { //number of replies to a thread before it wont get bumped anymore
			min: 10,
			max: 500
		},
		postFiles: { //number of files in a post
			max: 3
		},
		postFilesSize: { //in bytes, 10MB default
			max: 10485760
		},
		bannerFiles: {
			max: 10, //max number of banners uploadable at once
			total: 100, //max number of banners for a board in total
		},
		bannerFilesSize: { //in bytes, 10MB default
			max: 10485760
		},
		/* NOTE: postFilesSize and bannerFilesSize counts in bytes the amount of total data in form submission including
			other fields like message, name, etc. Therefore a very long message would reduce the space left for files very slightly.
			To counteract this, consider increasing postFilesSize and bannerFilesSize beyond your desired max filesize by a small margin */
		fieldLength: { //max length of fields in some forms
			//post form
			name: 100,
			email: 100,
			subject: 100,
			postpassword: 100,
			message: 4000,
			//reports/post actions
			report_reason: 100,
			ban_reason: 100,
			log_message: 100,
			//board creation
			uri: 50,
			boardname: 50,
			description: 100,
		},
		multiInputs: {
			//limits on certain requests that have multi-select. set any to 0 for unlimited
			posts: { //post actions (report, delete, spoiler, ban, etc).
				anon: 20, //separate for non-staff to prevent performance issues or abuse e.g. user mass self-deleting or reporting posts with single captcha
				staff: 100, //but to still allow mass moderation for staff
			},
			//note: room for more limits here if they are needed for other checkbox forms in future
		},
		customCss: {
			enabled: true, //allow custom css by board owners
			max: 10000, //max number of characters to allow
			strict: true, //enables filters to block certain strings in custom css
			filters: [
				'@',
				'url(',
			]
		}
	},

	//default frontend script settings
	frontendScriptDefault: {
		heightUnlimit: false, //allow images [M -1taller than window height
		hideRecursive: true, //hide replies to hidden OPs
		crispImages: false, //use crisp/pixelated image rendering
		hideThumbnails: false, //hide thumbnails
		nonColorIds: false, //dont color ids
		alwaysShowSpoilers: false , //always reveal spoiler text
		hidePostStubs: false, //hide hidden posts completely, dont even show it minimized
		smoothScrolling: false, //smooth scrolling. annoying for chrome users on long threads
		defaultVolume: 100, //default video/audio volume
		loop: true, //looping video/audio
		imageLoadingBars: true, //show loading bar for images, changes behaviour of right click save/open
		live: true, //update threads automatically
		scrollToPosts: false, //scroll to new posts
		localTime: true, //show local time for posts
		hour24Time: false, //show 24 hour time for posts
		relativeTime: true, //show relative time i.e. "5 minutes ago" for posts
		//note: relative time overrides other time options, they show on hover in the tooltip/"title" property
		notificationsEnabled: false, //show notifications for new posts in a thread
		notificationsYousOnly: true, //only show notifications for posts that quote you
		showYous: true, //show (You) next to name on your posts, and quotes linking to your post
	},

	//default board settings when a board is created
	boardDefaults: {
		theme: 'lain',
		codeTheme: 'ir-black',
		sfw: false, //safe for work board
		lockMode: 0, //board lock mode
		fileR9KMode: 0, //enfore unique files, 0=off, 1=per thread, 2=whole board
		messageR9KMode: 0, //enforce unique message, 0=off, 1=per thread, 2=whole board
		unlistedLocal: false, //board hidden from on-site board list and frontpage
		unlistedWebring: false, //board hidden from webring
		captchaMode: 0, //0=disabled, 1=for threads, 2=for all posts
		tphTrigger: 10, //numebr of threads in an hour before trigger action is activated
		pphTrigger: 50, //number of posts in an hour before ^
		//0=none, 1=captcha enable for threads, 2=captcha enable for all posts, 3=lock board
		tphTriggerAction: 1,
		pphTriggerAction: 2,
		//0=dont change, 1=unlock board/disable captcha, 2=lock thread creation/enable captcha for thread creation
		captchaReset: 0,
		lockReset: 0,
		forceAnon: false, //disable name and subject, only allow sage email
		sageOnlyEmail: false, //only allow sage email
		early404: true, //delete threads beyond the first 1/3 of pages with less than 5 replies
		ids: false, //show per thread poster ID based on ip
		flags: false, //show geo flags, requires nginx setup
		userPostDelete: true, //allow users to delete their posts
		userPostSpoiler: true, //allow user to spoiler their post files
		userPostUnlink: true, //allow user to unlink files from their post
		threadLimit: 200,
		replyLimit: 500,
		bumpLimit: 500,
		maxFiles: 1,
		forceReplyMessage: false,
		forceReplyFile: false,
		forceThreadMessage: false,
		forceThreadFile: false,
		forceThreadSubject: false,
		disableReplySubject: false,
		minThreadMessageLength: 0,
		minReplyMessageLength: 0,
		maxThreadMessageLength: 4000,
		maxReplyMessageLength: 4000,
		defaultName: 'Anon',
		customCSS: null,
		blockedCountries: [], //2 char ISO country codes to block
		disableOnionFilePosting: false,
		filters: [], //words/phrases to block
		filterMode: 0, //0=nothing, 1=prevent post, 2=auto ban
		filterBanDuration: 0, //duration (in ms) to ban if filter mode=2
		strictFiltering: false,
		announcement: {
			raw: null,
			markdown: null
		},
		allowedFileTypes: { //enable different types of files to be posted
			animatedImage: true,
			image: true,
			video: true,
			audio: true,
			other: false
		}
	},

};


code:
`nginx.example` - Example nginx config with letsencrypt. You need to update with the path of installation, certificate paths, domain names, etc.
`nginx_no_https.example` - Same thing, without https. Can be used for testing and development.
`nginx_advanced.example` - An advanced example with both clearnet and tor, with snippets to reduce repitition for a cleaner config


code:
upstream chan {
	server localhost:7000;
}

server {

	server_name domain.com www.domain.com;
	client_max_body_size 0; #limit handled by upstream for better errors
	server_tokens off;

	add_header Cache-Control "public";
	add_header Content-Security-Policy "default-src 'self'; img-src 'self' blob:; object-src 'self' blob:; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' https://www.youtube.com/embed/ https://www.bitchute.com/embed/";
	add_header Referrer-Policy "same-origin, strict-origin-when-cross-origin" always;
	add_header X-Frame-Options "sameorigin" always;
	add_header X-Content-Type-Options "nosniff" always;
	add_header X-XSS-Protection "1; mode=block" always;

	error_page 404 /404.html;
	error_page 500 /500.html;
	error_page 502 /502.html;
	error_page 503 /503.html;
	error_page 504 /504.html;
	location = /404.html {
		root /path/to/jschan/static/html;
		internal;
	}
	location = /500.html {
		root /path/to/jschan/static/html;
		internal;
	}
	location = /502.html {
		root /path/to/jschan/static/html;
		internal;
	}
	location = /503.html {
		root /path/to/jschan/static/html;
		internal;
	}
	location = /504.html {
		root /path/to/jschan/static/html;
		internal;
	}

	if ($request_uri ~ ^/(?!captcha|randombanner|forms|socket\.io)) {
		rewrite ^([^.\?]*[^/])$ $1/ redirect;
		rewrite ^(.+)/$ $1/index.html redirect;
	}

	location = /robots.txt {
		access_log off;
		add_header Content-Type text/plain;
		return 200 "User-agent: *\nDisallow:\n";
	}

	location = /site.webmanifest {
		access_log off;
		expires max;
		root /path/to/jschan/static/file;
		try_files $uri =404;
	}

	location = /browserconfig.xml {
		access_log off;
		expires max;
		root /path/to/jschan/static/file;
		try_files $uri =404;
	}

	location = /favicon.ico {
		access_log off;
		expires max;
		root /path/to/jschan/static/file;
		try_files $uri =404;
	}

	location = / {
		return 302 https://$host/index.html;
	}

	location /captcha {
		access_log off;
		root /path/to/jschan/static/captcha;
		if ($cookie_captchaid) {
			return 302 https://$host/captcha/$cookie_captchaid.jpg;
		}
		try_files /$cookie_captchaid.jpg @backend;
	}

	location / {
		proxy_buffering off;
		proxy_pass http://chan$request_uri;
		proxy_http_version 1.1;

		#websocket
		proxy_set_header Host $host;
		proxy_set_header Upgrade $http_upgrade;
		proxy_set_header Connection 'upgrade';
		proxy_cache_bypass $http_upgrade;

		proxy_set_header X-Forwarded-Proto https;
		proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
		proxy_set_header X-Real-IP $remote_addr;
		proxy_set_header X-Country-Code $geoip_country_code;
	}

	location @backend {
		proxy_buffering off;
		proxy_pass http://chan$request_uri;
		proxy_http_version 1.1;
		proxy_set_header X-Forwarded-Proto https;
		proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
		proxy_set_header X-Real-IP $remote_addr;
		proxy_set_header X-Country-Code $geoip_country_code;
		proxy_set_header Connection '';
		proxy_set_header Host $host;
	}

	# HTML
	location ~* \.html$ {
		expires 0;
		root /path/to/jschan/static/html;
		try_files $uri @backend;
	}

	# JSON
	location ~* \.json$ {
		expires 0;
		root /path/to/jschan/static/json;
		try_files $uri @backend;
		#some json files will build pages on-demand
	}

	# CSS
	location ~* \.css$ {
		access_log off;
		expires 1d;
		root /path/to/jschan/static;
		try_files $uri =404;
	}

	# Scripts
	location ~* \.js$ {
		expires 1d;
		access_log off;
		root /path/to/jschan/static;
		try_files $uri =404;
	}

	# Files (image, video, audio, other)
	location ~* \.(png|jpg|jpeg|bmp|gif|apng|webp|jfif|pjpeg|mkv|mp4|webm|mov|svg|flac|mp3|ogg|wav|opus)$ {
		access_log off;
		expires max;
		root /path/to/jschan/static;
		try_files $uri =404;
	}

	# Favicon, safari icons, apple-touch icon, browserconfig, mstiles, etc.
	location ~* \.(webmanifest|xml)$ {
		access_log off;
		expires max;
		root /path/to/jschan/static/file;
		try_files $uri =404;
	}

	# "Other" Files for custom type uploads, uses content-disposition to prevent rendering
	# inline in browser and will present a "save" dialog box. make sure these file
	# extensions match for mimes defined in configs/main.js "otherMimeTypes"
#	location ~* \.(txt)$ {
#		access_log off;
#		expires max;
#		add_header Cache-Control "public";
#		add_header X-Content-Type-Options "nosniff" always;
#		add_header Content-Disposition "attachment";
#		root /path/to/jschan/static;
#		try_files $uri =404;
#	}

	listen 443 ssl http2; # managed by Certbot
	listen [::]:443 ssl http2; # managed by Certbot
	ssl_certificate /etc/letsencrypt/live/domain.com/fullchain.pem; # managed by Certbot
	ssl_certificate_key /etc/letsencrypt/live/domain.com/privkey.pem; # managed by Certbot
	include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
	ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot

}

server {

	listen 80;
	listen [::]:80;
	server_name domain.com www.domain.com;

	if ($host = www.domain.com) {
		return 301 https://$host$request_uri;
	} # managed by Certbot

	if ($host = domain.com) {
		return 301 https://$host$request_uri;
	} # managed by Certbot

	return 444; # managed by Certbot

}

##OPTIONAL, tor config
#server {
#
#	server_name zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz.onion;
#	client_max_body_size 0; #limit handled by upstream for better errors
#	server_tokens off;
#
#	add_header Cache-Control "public";
#	add_header Content-Security-Policy "default-src 'self'; img-src 'self' blob:; object-src 'self' blob:; script-src 'self'; style-src 'self' 'unsafe-inline'";
#	add_header Referrer-Policy "same-origin" always;
#	add_header X-Frame-Options "sameorigin" always;
#	add_header X-Content-Type-Options "nosniff" always;
#	add_header X-XSS-Protection "1; mode=block" always;
#
#	error_page 404 /404.html;
#	error_page 500 /500.html;
#	error_page 502 /502.html;
#	error_page 503 /503.html;
#	error_page 504 /504.html;
#	location = /404.html {
#		root /path/to/jschan/static/html;
#		internal;
#	}
#	location = /500.html {
#		root /path/to/jschan/static/html;
#		internal;
#	}
#	location = /502.html {
#		root /path/to/jschan/static/html;
#		internal;
#	}
#	location = /503.html {
#		root /path/to/jschan/static/html;
#		internal;
#	}
#	location = /504.html {
#		root /path/to/jschan/static/html;
#		internal;
#	}
#
#	if ($request_uri ~ ^/(?!captcha|randombanner|forms|socket\.io)) {
#		rewrite ^([^.\?]*[^/])$ $1/ redirect;
#		rewrite ^(.+)/$ $1/index.html redirect;
#	}
#
#	location = /robots.txt {
#		access_log off;
#		add_header Content-Type text/plain;
#		return 200 "User-agent: *\nDisallow:\n";
#	}
#
#	location = /site.webmanifest {
#		access_log off;
#		expires max;
#		root /path/to/jschan/static/file;
#		try_files $uri =404;
#	}
#
#	location = /browserconfig.xml {
#		access_log off;
#		expires max;
#		root /path/to/jschan/static/file;
#		try_files $uri =404;
#	}
#
#	location = /favicon.ico {
#		access_log off;
#		expires max;
#		root /path/to/jschan/static/file;
#		try_files $uri =404;
#	}
#
#	location = / {
#		return 302 http://$host/index.html;
#	}
#
#	location /captcha {
#		access_log off;
#		root /path/to/jschan/static/captcha;
#		if ($cookie_captchaid) {
#			return 302 http://$host/captcha/$cookie_captchaid.jpg;
#		}
#		try_files /$cookie_captchaid.jpg @backend;
#	}
#
#	location / {
#		proxy_buffering off;
#		proxy_pass http://chan$request_uri;
#		proxy_http_version 1.1;
#
#		#websocket
#		proxy_set_header Host $host;
#		proxy_set_header Upgrade $http_upgrade;
#		proxy_set_header Connection 'upgrade';
#		proxy_cache_bypass $http_upgrade;
#
#		proxy_set_header X-Forwarded-Proto http;
#		proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
#		proxy_set_header X-Real-IP $remote_addr;
#		proxy_set_header X-Country-Code 'TOR';
#	}
#
#	location @backend {
#		proxy_buffering off;
#		proxy_pass http://chan$request_uri;
#		proxy_http_version 1.1;
#		proxy_set_header X-Forwarded-Proto http;
#		proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
#		proxy_set_header X-Real-IP $remote_addr;
#		proxy_set_header X-Country-Code 'TOR';
#		proxy_set_header Connection '';
#		proxy_set_header Host $host;
#	}
#
#	# HTML
#	location ~* \.html$ {
#		expires 0;
#		root /path/to/jschan/static/html;
#		try_files $uri @backend;
#	}
#
#	# JSON
#	location ~* \.json$ {
#		expires 0;
#		root /path/to/jschan/static/json;
#		try_files $uri @backend;
#		#json doesnt hit backend if it doesnt exist yet.
#	}
#
#	# CSS
#	location ~* \.css$ {
#		access_log off;
#		expires 1d;
#		root /path/to/jschan/static;
#		try_files $uri =404;
#	}
#
#	# Scripts
#	location ~* \.js$ {
#		expires 1d;
#		access_log off;
#		root /path/to/jschan/static;
#		try_files $uri =404;
#	}
#
#	# Files (image, video, audio, other)
#	location ~* \.(png|jpg|jpeg|bmp|gif|apng|webp|jfif|pjpeg|mkv|mp4|webm|mov|svg|mp3|ogg|wav|opus)$ {
#		access_log off;
#		expires max;
#		root /path/to/jschan/static;
#		try_files $uri =404;
#	}
#
#	# Favicon, safari icons, apple-touch icon, browserconfig, mstiles, etc.
#	location ~* \.(webmanifest|xml)$ {
#		access_log off;
#		expires max;
#		root /path/to/jschan/static/file;
#		try_files $uri =404;
#	}
#
#	# "Other" Files for custom type uploads, uses content-disposition to prevent rendering
#	# inline in browser and will present a "save" dialog box. make sure these file
#	# extensions match for mimes defined in configs/main.js "otherMimeTypes"
#	location ~* \.(txt)$ {
#		access_log off;
#		expires max;
#		add_header Cache-Control "public";
#		add_header X-Content-Type-Options "nosniff" always;
#		add_header Content-Disposition "attachment";
#		root /path/to/jschan/static;
#		try_files $uri =404;
#	}
#
#	listen 127.0.0.1:80;
#	listen [::1]:80;
#
#}


code:
upstream chan {
	server 127.0.0.1:7000;
}

server {

	server_name www.xxxxxxxx.onion xxxxxxxx.onion;

	client_max_body_size 0;
	listen unix:/var/run/nginx-tor.sock;
	allow "unix:";
	deny all;

	include /etc/nginx/snippets/security_headers.conf;
	include /etc/nginx/snippets/error_pages.conf;
	include /etc/nginx/snippets/jschan_routes_tor.conf;

}

server {

	server_name www.example.com example.com;

	client_max_body_size 0;
	#header will tell tor users accessing clearnet endpoint to use onion service
	add_header onion-location 'http://xxxxxxxxxxx.onion$request_uri';

	include /etc/nginx/snippets/security_headers.conf;
	include /etc/nginx/snippets/error_pages.conf;
	include /etc/nginx/snippets/jschan_routes.conf;

	listen [::]:443 ssl ipv6only=on; # managed by Certbot
	listen 443 ssl; # managed by Certbot
	ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; # managed by Certbot
	ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; # managed by Certbot
	include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
	ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot

}

server {
	if ($host = www.example.com) {
		return 301 https://$host$request_uri;
	} # managed by Certbot

	if ($host = example.com) {
		return 301 https://$host$request_uri;
	} # managed by Certbot

	server_name www.example.com example.com;

	listen 80;
	listen [::]:80;
	return 444; # managed by Certbot

}


code:
upstream chan {
	server localhost:7000;
}

server {

	server_name domain.com www.domain.com;
	client_max_body_size 0; #limit handled by upstream for better errors
	server_tokens off;

	add_header Cache-Control "public";
	add_header Content-Security-Policy "default-src 'self'; img-src 'self' blob:; object-src 'self' blob:; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' https://www.youtube.com/embed/ https://www.bitchute.com/embed/";
	add_header Referrer-Policy "same-origin, strict-origin-when-cross-origin" always;
	add_header X-Frame-Options "sameorigin" always;
	add_header X-Content-Type-Options "nosniff" always;
	add_header X-XSS-Protection "1; mode=block" always;

	error_page 404 /404.html;
	error_page 500 /500.html;
	error_page 502 /502.html;
	error_page 503 /503.html;
	error_page 504 /504.html;
	location = /404.html {
		root /path/to/jschan/static/html;
		internal;
	}
	location = /500.html {
		root /path/to/jschan/static/html;
		internal;
	}
	location = /502.html {
		root /path/to/jschan/static/html;
		internal;
	}
	location = /503.html {
		root /path/to/jschan/static/html;
		internal;
	}
	location = /504.html {
		root /path/to/jschan/static/html;
		internal;
	}

	if ($request_uri ~ ^/(?!captcha|randombanner|forms|socket\.io)) {
		rewrite ^([^.\?]*[^/])$ $1/ redirect;
		rewrite ^(.+)/$ $1/index.html redirect;
	}

	location = /robots.txt {
		access_log off;
		add_header Content-Type text/plain;
		return 200 "User-agent: *\nDisallow:\n";
	}

	location = /site.webmanifest {
		access_log off;
		expires max;
		root /path/to/jschan/static/file;
		try_files $uri =404;
	}

	location = /browserconfig.xml {
		access_log off;
		expires max;
		root /path/to/jschan/static/file;
		try_files $uri =404;
	}

	location = /favicon.ico {
		access_log off;
		expires max;
		root /path/to/jschan/static/file;
		try_files $uri =404;
	}

	location = / {
		return 302 http://$host/index.html;
	}

	location /captcha {
		access_log off;
		root /path/to/jschan/static/captcha;
		if ($cookie_captchaid) {
			return 302 http://$host/captcha/$cookie_captchaid.jpg;
		}
		try_files /$cookie_captchaid.jpg @backend;
	}

	location / {
		proxy_buffering off;
		proxy_pass http://chan$request_uri;
		proxy_http_version 1.1;

		#websocket
		proxy_set_header Host $host;
		proxy_set_header Upgrade $http_upgrade;
		proxy_set_header Connection 'upgrade';
		proxy_cache_bypass $http_upgrade;

		proxy_set_header X-Forwarded-Proto http;
		proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
		proxy_set_header X-Real-IP $remote_addr;
		proxy_set_header X-Country-Code $geoip_country_code;
	}

	location @backend {
		proxy_buffering off;
		proxy_pass http://chan$request_uri;
		proxy_http_version 1.1;
		proxy_set_header X-Forwarded-Proto http;
		proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
		proxy_set_header X-Real-IP $remote_addr;
		proxy_set_header X-Country-Code $geoip_country_code;
		proxy_set_header Connection '';
		proxy_set_header Host $host;
	}

	# HTML
	location ~* \.html$ {
		expires 0;
		root /path/to/jschan/static/html;
		try_files $uri @backend;
	}

	# JSON
	location ~* \.json$ {
		expires 0;
		root /path/to/jschan/static/json;
		try_files $uri @backend;
		#some json files will build pages on-demand
	}

	# CSS
	location ~* \.css$ {
		access_log off;
		expires 1d;
		root /path/to/jschan/static;
		try_files $uri =404;
	}

	# Scripts
	location ~* \.js$ {
		expires 1d;
		access_log off;
		root /path/to/jschan/static;
		try_files $uri =404;
	}

	# Files (image, video, audio, other)
	location ~* \.(png|jpg|jpeg|bmp|gif|apng|webp|pjpeg|jfif|mkv|mp4|webm|mov|svg|flac|mp3|ogg|wav|opus)$ {
		access_log off;
		expires max;
		root /path/to/jschan/static;
		try_files $uri =404;
	}

	# Favicon, safari icons, apple-touch icon, browserconfig, mstiles, etc.
	location ~* \.(webmanifest|xml)$ {
		access_log off;
		expires max;
		root /path/to/jschan/static/file;
		try_files $uri =404;
	}

	# "Other" Files for custom type uploads, uses content-disposition to prevent rendering
	# inline in browser and will present a "save" dialog box. make sure these file
	# extensions match for mimes defined in configs/main.js "otherMimeTypes"
#	location ~* \.(txt)$ {
#		access_log off;
#		expires max;
#		add_header Cache-Control "public";
#		add_header X-Content-Type-Options "nosniff" always;
#		add_header Content-Disposition "attachment";
#		root /path/to/jschan/static;
#		try_files $uri =404;
#	}

	listen 80;
	listen [::]:80;

}

##OPTIONAL, tor config
#server {
#
#	server_name zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz.onion;
#	client_max_body_size 0; #limit handled by upstream for better errors
#	server_tokens off;
#
#	error_page 404 /404.html;
#	error_page 500 /500.html;
#	error_page 502 /502.html;
#	error_page 503 /503.html;
#	error_page 504 /504.html;
#	location = /404.html {
#		root /path/to/jschan/static/html;
#		internal;
#	}
#	location = /500.html {
#		root /path/to/jschan/static/html;
#		internal;
#	}
#	location = /502.html {
#		root /path/to/jschan/static/html;
#		internal;
#	}
#	location = /503.html {
#		root /path/to/jschan/static/html;
#		internal;
#	}
#	location = /504.html {
#		root /path/to/jschan/static/html;
#		internal;
#	}
#
#	add_header Cache-Control "public";
#	add_header Content-Security-Policy "default-src 'self'; img-src 'self' blob:; object-src 'self' blob:; script-src 'self'; style-src 'self' 'unsafe-inline'";
#	add_header Referrer-Policy "same-origin" always;
#	add_header X-Frame-Options "sameorigin" always;
#	add_header X-Content-Type-Options "nosniff" always;
#	add_header X-XSS-Protection "1; mode=block" always;
#
#	if ($request_uri ~ ^/(?!captcha|randombanner|forms|socket\.io)) {
#		rewrite ^([^.\?]*[^/])$ $1/ redirect;
#		rewrite ^(.+)/$ $1/index.html redirect;
#	}
#
#	location = /robots.txt {
#		access_log off;
#		add_header Content-Type text/plain;
#		return 200 "User-agent: *\nDisallow:\n";
#	}
#
#	location = /site.webmanifest {
#		access_log off;
#		expires max;
#		root /path/to/jschan/static/file;
#		try_files $uri =404;
#	}
#
#	location = /browserconfig.xml {
#		access_log off;
#		expires max;
#		root /path/to/jschan/static/file;
#		try_files $uri =404;
#	}
#
#	location = /favicon.ico {
#		access_log off;
#		expires max;
#		root /path/to/jschan/static/file;
#		try_files $uri =404;
#	}
#
#	location = / {
#		return 302 http://$host/index.html;
#	}
#
#	location /captcha {
#		access_log off;
#		root /path/to/jschan/static/captcha;
#		if ($cookie_captchaid) {
#			return 302 http://$host/captcha/$cookie_captchaid.jpg;
#		}
#		try_files /$cookie_captchaid.jpg @backend;
#	}
#
#	location / {
#		proxy_buffering off;
#		proxy_pass http://chan$request_uri;
#		proxy_http_version 1.1;
#
#		#websocket
#		proxy_set_header Host $host;
#		proxy_set_header Upgrade $http_upgrade;
#		proxy_set_header Connection 'upgrade';
#		proxy_cache_bypass $http_upgrade;
#
#		proxy_set_header X-Forwarded-Proto http;
#		proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
#		proxy_set_header X-Real-IP $remote_addr;
#		proxy_set_header X-Country-Code 'TOR';
#	}
#
#	location @backend {
#		proxy_buffering off;
#		proxy_pass http://chan$request_uri;
#		proxy_http_version 1.1;
#		proxy_set_header X-Forwarded-Proto http;
#		proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
#		proxy_set_header X-Real-IP $remote_addr;
#		proxy_set_header X-Country-Code 'TOR';
#		proxy_set_header Connection '';
#		proxy_set_header Host $host;
#	}
#
#	# HTML
#	location ~* \.html$ {
#		expires 0;
#		root /path/to/jschan/static/html;
#		try_files $uri @backend;
#	}
#
#	# JSON
#	location ~* \.json$ {
#		expires 0;
#		root /path/to/jschan/static/json;
#		try_files $uri =404;
#		#json doesnt hit backend if it doesnt exist yet.
#	}
#
#	# CSS
#	location ~* \.css$ {
#		access_log off;
#		expires 1d;
#		root /path/to/jschan/static;
#		try_files $uri =404;
#	}
#
#	# Scripts
#	location ~* \.js$ {
#		expires 1d;
#		access_log off;
#		root /path/to/jschan/static;
#		try_files $uri =404;
#	}
#
#	# Files (image, video, audio, other)
#	location ~* \.(png|jpg|jpeg|bmp|gif|apng|webp|jfif|pjpeg|mkv|mp4|webm|mov|svg|mp3|ogg|wav|opus)$ {
#		access_log off;
#		expires max;
#		root /path/to/jschan/static;
#		try_files $uri =404;
#	}
#
#	# Favicon, safari icons, apple-touch icon, browserconfig, mstiles, etc.
#	location ~* \.(webmanifest|xml)$ {
#		access_log off;
#		expires max;
#		root /path/to/jschan/static/file;
#		try_files $uri =404;
#	}
#
#	# "Other" Files for custom type uploads, uses content-disposition to prevent rendering
#	# inline in browser and will present a "save" dialog box. make sure these file
#	# extensions match for mimes defined in configs/main.js "otherMimeTypes"
#	location ~* \.(txt)$ {
#		access_log off;
#		expires max;
#		add_header Cache-Control "public";
#		add_header X-Content-Type-Options "nosniff" always;
#		add_header Content-Disposition "attachment";
#		root /path/to/jschan/static;
#		try_files $uri =404;
#	}
#
#	listen 127.0.0.1:80;
#	listen [::1]:80;
#
#}


code:
error_page 404 /404.html;
error_page 500 /500.html;
error_page 502 /502.html;
error_page 503 /503.html;
error_page 504 /504.html;
location = /404.html {
        root /path/to/jschan/static/html;
        internal;
}
location = /500.html {
        root /path/to/jschan/static/html;
        internal;
}
location = /502.html {
        root /path/to/jschan/static/html;
        internal;
}
location = /503.html {
        root /path/to/jschan/static/html;
        internal;
}
location = /504.html {
        root /path/to/jschan/static/html;
        internal;
}


code:
if ($request_uri ~ ^/(?!captcha|randombanner|forms|logout|socket\.io)) {
	rewrite ^([^.\?]*[^/])$ $1/ redirect;
	rewrite ^(.+)/$ $1/index.html redirect;
}

location = /robots.txt {
	access_log off;
	add_header Content-Type text/plain;
	return 200 "User-agent: *\nDisallow:\n";
}

location = /site.webmanifest {
	access_log off;
	expires max;
	root /path/to/jschan/static/file;
	try_files $uri =404;
}

location = /browserconfig.xml {
	access_log off;
	expires max;
	root /path/to/jschan/static/file;
	try_files $uri =404;
}

location = /favicon.ico {
	access_log off;
	expires max;
	root /path/to/jschan/static/file;
	try_files $uri =404;
}

location = / {
	return 302 https://$host/index.html;
}

location /captcha {
	root /path/to/jschan/static/captcha;
	if ($cookie_captchaid) {
		return 302 https://$host/captcha/$cookie_captchaid.jpg;
	}
	try_files /$cookie_captchaid.jpg @backend;
}

location / {
	proxy_buffering off;
	proxy_pass http://chan$request_uri;
	proxy_http_version 1.1;

	proxy_set_header Host $host;
	proxy_set_header Upgrade $http_upgrade;
	proxy_set_header Connection 'upgrade';
	proxy_cache_bypass $http_upgrade;

	proxy_set_header X-Forwarded-Proto https;
	proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
	proxy_set_header X-Real-IP $remote_addr;
	proxy_set_header X-Country-Code $geoip_country_code;
}

location @backend {
	proxy_buffering off;
	proxy_pass http://chan$request_uri;
	proxy_http_version 1.1;
	proxy_set_header X-Forwarded-Proto https;
	proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
	proxy_set_header X-Real-IP $remote_addr;
	proxy_set_header X-Country-Code $geoip_country_code;
	proxy_set_header Connection '';
	proxy_set_header Host $host;
}

# HTML
location ~* \.html$ {
	expires 0;
	default_type text/html; #needed for cache control private in backend
	root /path/to/jschan/static/html;
	try_files $uri @backend;
}

# JSON
location ~* \.json$ {
	expires 0;
	root /path/to/jschan/static/json;
	try_files $uri @backend;
	#json doesnt hit backend if it doesnt exist yet.
}

# CSS
location ~* \.css$ {
	access_log off;
	expires 1w;
	root /path/to/jschan/static;
	try_files $uri =404;
}

# Scripts
location ~* \.js$ {
	expires 1w;
	access_log off;
	root /path/to/jschan/static;
	try_files $uri =404;
}

# Files (image, video, audio, other)
location ~* \.(png|jpg|jpeg|webmanifest|apng|bmp|webp|pjpeg|jfif|gif|mp4|webm|mov|mkv|svg|flac|mp3|ogg|wav|opus)$ {
	access_log off;
	expires max;
	root /path/to/jschan/static;
	try_files $uri =404;
}

# inline in browser so even HTML filetypes can be offered and will present a "save" dialog box
location ~* \.(txt|bin)$ {
	access_log off;
	expires max;
	add_header Cache-Control "public";
	add_header X-Content-Type-Options "nosniff" always;
	add_header Content-Disposition "attachment";
	root /path/to/jschan/static;
	try_files $uri =404;
}


code:
if ($request_uri ~ ^/(?!captcha|randombanner|forms|logout|socket\.io)) {
	rewrite ^([^.\?]*[^/])$ $1/ redirect;
	rewrite ^(.+)/$ $1/index.html redirect;
}

location = /robots.txt {
	access_log off;
	add_header Content-Type text/plain;
	return 200 "User-agent: *\nDisallow:\n";
}

location = /site.webmanifest {
	access_log off;
	expires max;
	root /path/to/jschan/static/file;
	try_files $uri =404;
}

location = /browserconfig.xml {
	access_log off;
	expires max;
	root /path/to/jschan/static/file;
	try_files $uri =404;
}

location = /favicon.ico {
	access_log off;
	expires max;
	root /path/to/jschan/static/file;
	try_files $uri =404;
}

location = / {
	return 302 http://$host/index.html;
}

location /captcha {
	root /path/to/jschan/static/captcha;
	if ($cookie_captchaid) {
		return 302 http://$host/captcha/$cookie_captchaid.jpg;
	}
	try_files /$cookie_captchaid.jpg @backend;
}

location / {
	proxy_buffering off;
	proxy_pass http://chan$request_uri;
	proxy_http_version 1.1;

	proxy_set_header Host $host;
	proxy_set_header Upgrade $http_upgrade;
	proxy_set_header Connection 'upgrade';
	proxy_cache_bypass $http_upgrade;

	proxy_set_header X-Forwarded-Proto http;
	proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
	proxy_set_header X-Real-IP $remote_addr;
	proxy_set_header X-Country-Code 'TOR';
}

location @backend {
	proxy_buffering off;
	proxy_pass http://chan$request_uri;
	proxy_http_version 1.1;
	proxy_set_header X-Forwarded-Proto http;
	proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
	proxy_set_header X-Real-IP $remote_addr;
	proxy_set_header X-Country-Code 'TOR';
	proxy_set_header Connection '';
	proxy_set_header Host $host;
}

# HTML
location ~* \.html$ {
	expires 0;
	default_type text/html; #needed for cache control private in backend
	root /path/to/jschan/static/html;
	try_files $uri @backend;
}

# JSON
location ~* \.json$ {
	expires 0;
	root /path/to/jschan/static/json;
	try_files $uri @backend;
	#json doesnt hit backend if it doesnt exist yet.
}

# CSS
location ~* \.css$ {
	access_log off;
	expires 1w;
	root /path/to/jschan/static;
	try_files $uri =404;
}

# Scripts
location ~* \.js$ {
	expires 1w;
	access_log off;
	root /path/to/jschan/static;
	try_files $uri =404;
}

# Files (image, video, audio, other)
location ~* \.(png|jpg|jpeg|webmanifest|apng|bmp|webp|pjpeg|jfif|gif|mp4|webm|mov|mkv|svg|flac|mp3|ogg|wav|opus)$ {
	access_log off;
	expires max;
	root /path/to/jschan/static;
	try_files $uri =404;
}

# inline in browser so even HTML filetypes can be offered and will present a "save" dialog box
location ~* \.(txt|bin)$ {
	access_log off;
	expires max;
	add_header Cache-Control "public";
	add_header X-Content-Type-Options "nosniff" always;
	add_header Content-Disposition "attachment";
	root /path/to/jschan/static;
	try_files $uri =404;
}


code:
add_header Cache-Control "public";
add_header Content-Security-Policy "default-src 'self'; img-src 'self' blob:; object-src 'self' blob:; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' https://www.youtube.com/embed/ https://www.bitchute.com/embed/";
add_header Referrer-Policy "same-origin, strict-origin-when-cross-origin" always;
add_header X-Frame-Options "sameorigin" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;


code:
'use strict';

const express  = require('express')
	, router = express.Router()
	, Boards = require(__dirname+'/../db/boards.js')
	//middlewares
	, torPreBypassCheck = require(__dirname+'/../helpers/checks/torprebypass.js')
	, geoAndTor = require(__dirname+'/../helpers/geoip.js')
	, processIp = require(__dirname+'/../helpers/processip.js')
	, calcPerms = require(__dirname+'/../helpers/checks/calcpermsmiddleware.js')
	, hasPerms = require(__dirname+'/../helpers/checks/haspermsmiddleware.js')
	, paramConverter = require(__dirname+'/../helpers/paramconverter.js')
	, numFiles = require(__dirname+'/../helpers/numfiles.js')
	, banCheck = require(__dirname+'/../helpers/checks/bancheck.js')
	, isLoggedIn = require(__dirname+'/../helpers/checks/isloggedin.js')
	, verifyCaptcha = require(__dirname+'/../helpers/captcha/verify.js')
	, csrf = require(__dirname+'/../helpers/checks/csrfmiddleware.js')
	, useSession = require(__dirname+'/../helpers/usesession.js')
	, sessionRefresh = require(__dirname+'/../helpers/sessionrefresh.js')
	, dnsblCheck = require(__dirname+'/../helpers/checks/dnsbl.js')
	, blockBypassCheck = require(__dirname+'/../helpers/checks/blockbypass.js')
	, { handleBannerFiles, handlePostFilesEarlyTor, handlePostFiles } = require(__dirname+'/../helpers/filemiddlewares.js')
	//controllers
	, deleteBoardController = require(__dirname+'/forms/deleteboard.js')
	, editBansController = require(__dirname+'/forms/editbans.js')
	, appealController = require(__dirname+'/forms/appeal.js')
	, globalActionController = require(__dirname+'/forms/globalactions.js')
	, actionController = require(__dirname+'/forms/actions.js')
//	, addBanController = require(__dirname+'/forms/addban.js')
	, addNewsController = require(__dirname+'/forms/addnews.js')
	, deleteNewsController = require(__dirname+'/forms/deletenews.js')
	, uploadBannersController = require(__dirname+'/forms/uploadbanners.js')
	, deleteBannersController = require(__dirname+'/forms/deletebanners.js')
	, boardSettingsController = require(__dirname+'/forms/boardsettings.js')
	, transferController = require(__dirname+'/forms/transfer.js')
	, resignController = require(__dirname+'/forms/resign.js')
	, deleteAccountController = require(__dirname+'/forms/deleteaccount.js')
	, loginController = require(__dirname+'/forms/login.js')
	, registerController = require(__dirname+'/forms/register.js')
	, changePasswordController = require(__dirname+'/forms/changepassword.js')
	, editAccountsController = require(__dirname+'/forms/editaccounts.js')
	, globalSettingsController = require(__dirname+'/forms/globalsettings.js')
	, createBoardController = require(__dirname+'/forms/create.js')
	, makePostController = require(__dirname+'/forms/makepost.js')
	, editPostController = require(__dirname+'/forms/editpost.js')
	, newCaptcha = require(__dirname+'/../models/forms/newcaptcha.js')
	, blockBypass = require(__dirname+'/../models/forms/blockbypass.js')
	, logout = require(__dirname+'/../models/forms/logout.js');

//make new post
router.post('/board/:board/post', geoAndTor, handlePostFilesEarlyTor, torPreBypassCheck, processIp, useSession, sessionRefresh, Boards.exists, calcPerms, banCheck, handlePostFiles,
	paramConverter, verifyCaptcha, numFiles, blockBypassCheck, dnsblCheck, makePostController);
router.post('/board/:board/modpost', geoAndTor, handlePostFilesEarlyTor, torPreBypassCheck, processIp, useSession, sessionRefresh, Boards.exists, calcPerms, banCheck, isLoggedIn, hasPerms(3), handlePostFiles,
	paramConverter, csrf, numFiles, blockBypassCheck, dnsblCheck, makePostController); //mod post has token instead of captcha

//post actions
router.post('/board/:board/actions', geoAndTor, torPreBypassCheck, processIp, useSession, sessionRefresh, Boards.exists, calcPerms, banCheck, paramConverter, verifyCaptcha, actionController); //public, with captcha
router.post('/board/:board/modactions', geoAndTor, torPreBypassCheck, processIp, useSession, sessionRefresh, csrf, Boards.exists, calcPerms, banCheck, isLoggedIn, hasPerms(3), paramConverter, actionController); //board manage page
router.post('/global/actions', geoAndTor, torPreBypassCheck, processIp, useSession, sessionRefresh, csrf, calcPerms, isLoggedIn, hasPerms(1), paramConverter, globalActionController); //global manage page
//appeal ban
router.post('/appeal', geoAndTor, torPreBypassCheck, processIp, useSession, sessionRefresh, paramConverter, verifyCaptcha, appealController);
//edit post
router.post('/editpost', geoAndTor, torPreBypassCheck, processIp, useSession, sessionRefresh, csrf, paramConverter, Boards.bodyExists, calcPerms, hasPerms(3), editPostController);

//board management forms
router.post('/board/:board/transfer', /*geoAndTor, torPreBypassCheck, processIp,*/ useSession, sessionRefresh, csrf, Boards.exists, calcPerms, isLoggedIn, hasPerms(2), paramConverter, transferController);
router.post('/board/:board/settings', /*geoAndTor, torPreBypassCheck, processIp,*/ useSession, sessionRefresh, csrf, Boards.exists, calcPerms, isLoggedIn, hasPerms(2), paramConverter, boardSettingsController);
router.post('/board/:board/addbanners', /*geoAndTor, torPreBypassCheck, processIp,*/ useSession, sessionRefresh, handleBannerFiles, csrf, Boards.exists, calcPerms, isLoggedIn, hasPerms(2), paramConverter, numFiles, uploadBannersController); //add banners
router.post('/board/:board/deletebanners', /*geoAndTor, torPreBypassCheck, processIp,*/ useSession, sessionRefresh, csrf, Boards.exists, calcPerms, isLoggedIn, hasPerms(2), paramConverter, deleteBannersController); //delete banners
//router.post('/board/:board/addban', geoAndTor, torPreBypassCheck, processIp, useSession, sessionRefresh, csrf, Boards.exists, calcPerms, isLoggedIn, hasPerms(3), paramConverter, addBanController); //add ban manually without post
router.post('/board/:board/editbans', /*geoAndTor, torPreBypassCheck, processIp,*/ useSession, sessionRefresh, csrf, Boards.exists, calcPerms, isLoggedIn, hasPerms(3), paramConverter, editBansController); //edit bans
router.post('/board/:board/deleteboard', /*geoAndTor, torPreBypassCheck, processIp,*/ useSession, sessionRefresh, csrf, Boards.exists, calcPerms, isLoggedIn, hasPerms(2), deleteBoardController); //delete board

//global management forms
router.post('/global/editbans', useSession, sessionRefresh, csrf, calcPerms, isLoggedIn, hasPerms(1), paramConverter, editBansController); //remove bans
//commented out for now, because we cant add a manual ban based on a non existing hash suffix (or fetch the full hash from a non existing post), and the user wouldnt know if it the post didn't exist so its pointless anyway.
//router.post('/global/addban', geoAndTor, torPreBypassCheck, processIp, useSession, sessionRefresh, csrf, calcPerms, isLoggedIn, hasPerms(1), paramConverter, addBanController); //add ban manually without post
router.post('/global/deleteboard', useSession, sessionRefresh, csrf, paramConverter, calcPerms, isLoggedIn, hasPerms(1), deleteBoardController); //delete board
router.post('/global/addnews', useSession, sessionRefresh, csrf, calcPerms, isLoggedIn, hasPerms(0), addNewsController); //add new newspost
router.post('/global/deletenews', useSession, sessionRefresh, csrf, calcPerms, isLoggedIn, hasPerms(0), paramConverter, deleteNewsController); //delete news
router.post('/global/editaccounts', useSession, sessionRefresh, csrf, calcPerms, isLoggedIn, hasPerms(0), paramConverter, editAccountsController); //account editing
router.post('/global/settings', useSession, sessionRefresh, csrf, calcPerms, isLoggedIn, hasPerms(0), paramConverter, globalSettingsController); //global settings

//create board
router.post('/create', geoAndTor, torPreBypassCheck, processIp, useSession, sessionRefresh, isLoggedIn, verifyCaptcha, calcPerms, createBoardController);
//accounts
router.post('/login', useSession, loginController);
router.post('/logout', useSession, logout);
router.post('/register', geoAndTor, torPreBypassCheck, processIp, useSession, sessionRefresh, verifyCaptcha, calcPerms, registerController);
router.post('/changepassword', geoAndTor, torPreBypassCheck, processIp, useSession, sessionRefresh, verifyCaptcha, changePasswordController);
router.post('/resign', useSession, sessionRefresh, csrf, calcPerms, isLoggedIn, paramConverter, resignController);
router.post('/deleteaccount', useSession, sessionRefresh, csrf, calcPerms, isLoggedIn, paramConverter, deleteAccountController);

//removes captcha cookie, for refreshing for noscript users
router.post('/newcaptcha', newCaptcha);
//solve captcha for block bypass
router.post('/blockbypass', geoAndTor, /*torPreBypassCheck,*/ processIp, verifyCaptcha, blockBypass);

module.exports = router;


code:
'use strict';

const { Posts } = require(__dirname+'/../../db/')
	, { globalLimits } = require(__dirname+'/../../configs/main.js')
	, actionHandler = require(__dirname+'/../../models/forms/actionhandler.js')
	, dynamicResponse = require(__dirname+'/../../helpers/dynamic.js')
	, actionChecker = require(__dirname+'/../../helpers/checks/actionchecker.js');

module.exports = async (req, res, next) => {

	const errors = [];

	//make sure they checked 1-10 posts
	if (!req.body.checkedposts || req.body.checkedposts.length === 0) {
		errors.push('Must select at least one post');
	} else if (res.locals.permLevel >= 4 && globalLimits.multiInputs.posts.anon
		&& req.body.checkedposts.length > globalLimits.multiInputs.posts.anon) {
		errors.push(`Must not select >${globalLimits.multiInputs.posts.anon} posts per request`);
	} else if (globalLimits.multiInputs.posts.staff
		&& req.body.checkedposts.length > globalLimits.multiInputs.posts.staff) {
		errors.push(`Must not select >${globalLimits.multiInputs.posts.staff} posts per request`);
	}
	//checked reports
	if (req.body.checkedreports) {
		if (!req.body.report_ban) {
			errors.push('Must select a report action if checked reports');
		}
		if (req.body.checkedreports.length > req.body.checkedposts.length*5) {
			//5 reports max per post
			errors.push('Invalid number of reports checked');
		}
	} else if (!req.body.checkedreports && req.body.report_ban) {
		errors.push('Must select post and reports to ban reporter');
	}

	res.locals.actions = actionChecker(req);

	//make sure they selected at least 1 action
	if (res.locals.actions.validActions.length === 0) {
		errors.push('No actions selected');
	}

	//check if they have permission to perform the actions
	if (res.locals.permLevel > res.locals.actions.authRequired) {
		errors.push('No permission');
	}
	if (res.locals.permLevel >= 4) {
		if (req.body.delete && !res.locals.board.settings.userPostDelete) {
			errors.push('Post deletion is disabled on this board');
		}
		if (req.body.spoiler && !res.locals.board.settings.userPostSpoiler) {
			errors.push('File spoilers are disabled on this board');
		}
		if (req.body.unlink_file && !res.locals.board.settings.userPostUnlink) {
			errors.push('File unlinking is disabled on this board');
		}
	}

	//check that actions are valid
	if (req.body.edit && req.body.checkedposts && req.body.checkedposts.length > 1) {
		errors.push('Must select only 1 post for edit action');
	}
	if (req.body.postpassword && req.body.postpassword.length > globalLimits.fieldLength.postpassword) {
		errors.push(`Password must be ${globalLimits.fieldLength.postpassword} characters or less`);
	}
	if (req.body.report_reason && req.body.report_reason.length > globalLimits.fieldLength.report_reason) {
		errors.push(`Report must be ${globalLimits.fieldLength.report_reason} characters or less`);
	}
	if (req.body.ban_reason && req.body.ban_reason.length > globalLimits.fieldLength.ban_reason) {
		errors.push(`Ban reason must be ${globalLimits.fieldLength.ban_reason} characters or less`);
	}
	if (req.body.log_message && req.body.log_message.length > globalLimits.fieldLength.log_message) {
		errors.push(`Modlog message must be ${globalLimits.fieldLength.log_message} characters or less`);
	}
	if ((req.body.report || req.body.global_report) && (!req.body.report_reason || req.body.report_reason.length === 0)) {
		errors.push('Reports must have a reason');
	}
	if (req.body.move) {
		if (!req.body.move_to_thread) {
			errors.push('Must input destinaton thread number to move posts');
		} else if (req.body.move_to_thread) {
			const destinationThread = await Posts.threadExists(req.params.board, req.body.move_to_thread);
			if (!destinationThread) {
				errors.push('Destination thread for move does not exist');
			}
		}
	}

	if (errors.length > 0) {
		return dynamicResponse(req, res, 400, 'message', {
			'title': 'Bad request',
			'errors': errors,
			'redirect': `/${req.params.board}/`
		})
	}

	try {
		res.locals.posts = await Posts.getPosts(req.params.board, req.body.checkedposts, true);
	} catch (err) {
		return next(err);
	}

	if (!res.locals.posts || res.locals.posts.length === 0) {
		return dynamicResponse(req, res, 404, 'message', {
			'title': 'Not found',
			'error': 'Selected posts not found',
			'redirect': `/${req.params.board}/`
		})
	}

	if (req.body.edit) {
		//edit post, only allowing one
		return res.render('editpost', {
			'post': res.locals.posts[0],
			'csrf': req.csrfToken(),
			'referer': (req.headers.referer || `/${res.locals.posts[0].board}/manage/thread/${res.locals.posts[0].thread || res.locals.posts[0].postId}.html`) + `#${res.locals.posts[0].postId}`,
		});
	} else if (req.body.move) {
		res.locals.posts = res.locals.posts.filter(p => {
			//filter to remove any posts already in the thread (or the OP) of move destionation
			return p.postId !== req.body.move_to_thread && p.thread !== req.body.move_to_thread;
		});
		if (res.locals.posts.length === 0) {
			return dynamicResponse(req, res, 429, 'message', {
				'title': 'Conflict',
				'error': 'Destionation thread cannot match source thread for move action',
				'redirect': `/${req.params.board}/`
			});
		}
	}

	try {
		await actionHandler(req, res, next);
	} catch (err) {
		return next(err);
	}

}


code:
'use strict';

const { globalLimits, ipHashPermLevel } = require(__dirname+'/../../configs/main.js')
	, addBan = require(__dirname+'/../../models/forms/addban.js')
	, dynamicResponse = require(__dirname+'/../../helpers/dynamic.js')
	, { isIP } = require('net');

module.exports = async (req, res, next) => {

	const errors = [];

	if (!req.body.ip || req.body.ip.length === 0) {
		errors.push('Missing IP/hash input');
	} else if (req.body.ip.length > 50) {
		errors.push('IP/hash input must be less than 50 characters');
	} else if (res.locals.permLevel > ipHashPermLevel && (isIP(req.body.ip) || req.body.ip.length !== 10)) {
		errors.push('Invalid hash input');
	}
	if (req.body.ban_reason && req.body.ban_reason.length > globalLimits.fieldLength.ban_reason) {
		errors.push(`Ban reason must be ${globalLimits.fieldLength.ban_reason} characters or less`);
	}
	if (req.body.log_message && req.body.log_message.length > globalLimits.fieldLength.log_message) {
		errors.push(`Modlog message must be ${globalLimits.fieldLength.log_message} characters or less`);
	}

	let redirect = req.headers.referer;
	if (!redirect) {
		if (!req.params.board) {
			redirect = '/globalmanage/bans.html';
		} else {
			redirect = `/${req.params.board}/manage/bans.html`;
		}
	}

	if (errors.length > 0) {
		return dynamicResponse(req, res, 400, 'message', {
			'title': 'Bad request',
			'errors': errors,
			redirect,
		});
	}

	try {
		await addBan(req, res, redirect);
	} catch (err) {
		return next(err);
	}

}