如何部署导航网站源码并配置自己的网址目录

运营教程5小时前发布 jdkjadmin
0 00
如何部署导航网站源码并配置自己的网址目录

如何部署导航网站源码并配置自己的网址目录

网址导航看起来只是把一组链接排成页面,实际部署时仍要处理目录维护、输入校验、域名解析、HTTPS 和静态文件服务等问题。本文从一个可运行的静态导航站开始:网址数据放在 JSON 文件中,页面使用原生 HTML、CSS 和 JavaScript,不依赖数据库或前端构建工具;之后用 Nginx 和 Docker 部署,并说明如何绑定自己的域名。

这种方案适合个人主页、团队内部入口页、项目链接集合和小型公开导航。它不是带用户账号、在线编辑和权限管理的完整内容管理系统。如果多人需要通过网页编辑链接,或需要用户收藏、登录、统计访问等功能,应在此基础上增加后端和访问控制,不能仅靠静态文件实现。

一、先明确部署边界

静态导航站通常由网页文件和链接数据组成。浏览器下载网页后,在本地显示目录、过滤分类并搜索;服务器只负责返回文件。它不需要数据库,也不需要为每个访客运行应用进程。

  • 适合:链接变化不频繁、由站点维护者更新内容的目录站。
  • 不适合直接承担:用户提交链接、账号权限、动态审核、个性化收藏、后台管理等业务。
  • 需要注意:公开页面中的链接对所有访客可见。把内网地址写进页面,不等于建立了安全的内网访问控制。
  • 部署责任:托管服务商、域名注册商和 DNS 服务商通常是不同的服务;部署文件不会自动完成域名解析或 HTTPS 配置。

下面的示例采用一项明确的应用策略:最多展示 300 个有效网址;标题不超过 80 个字符,分类不超过 40 个字符,说明不超过 200 个字符;链接长度不超过 2048 个字符,只允许 HTTP 和 HTTPS。它们是示例代码主动设定的限制,不是互联网协议规定的统一上限。实际项目可以调整,但应保持前后端规则一致。

二、准备项目文件

创建目录 my-nav,文件结构如下:

my-nav/
├── index.html
├── sites.json
├── app.js
├── styles.css
├── Dockerfile
└── nginx.conf

网页和数据分开,维护者只需编辑 sites.json 就能增删目录项。页面不会把目录数据当作 HTML 插入,因此链接名称中的特殊字符会作为文本显示,而不是被浏览器解释成标签。

1. 编写链接数据

新建 sites.json,初始内容如下:

[
  {
    "title": "MDN Web Docs",
    "url": "https://developer.mozilla.org/",
    "category": "开发",
    "description": "Web 平台、HTML、CSS 和 JavaScript 文档"
  },
  {
    "title": "GitHub",
    "url": "https://github.com/",
    "category": "开发",
    "description": "代码托管与协作平台"
  },
  {
    "title": "Example 内部入口",
    "url": "https://intranet.example.com/",
    "category": "工作",
    "description": "仅适用于已接入组织网络的人员"
  }
]

JSON 是严格格式:字符串必须使用双引号,不能写注释,数组最后一项后不能保留逗号。URL 应使用完整地址,例如 https://example.com/;如果写成 example.com,浏览器可能把它当成当前站点下的相对路径。

内部系统入口是否应该出现在公开导航中,要结合访问控制判断。把内网地址公开会暴露主机名、系统用途等信息,且可能诱使不具备访问权限的访客点击。真正的保护应由 VPN、身份认证、网络隔离和服务端权限控制提供。

2. 编写页面结构

新建 index.html。页面包含标题、搜索框、分类选择框和卡片容器;样式与逻辑分别放在独立文件中。

<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="description" content="按分类整理的常用网址导航">
  <title>我的网址导航</title>
  <link rel="stylesheet" href="/styles.css">
  <script src="/app.js" defer></script>
</head>
<body>
  <main class="page">
    <header class="header">
      <p class="eyebrow">LINK DIRECTORY</p>
      <h1>我的网址导航</h1>
      <p>把常用入口整理在一个页面中。</p>
    </header>

    <section class="tools" aria-label="筛选网址">
      <label for="search">搜索</label>
      <input id="search" type="search" placeholder="搜索名称、分类或说明">
      <label for="category">分类</label>
      <select id="category">
        <option value="">全部分类</option>
      </select>
    </section>

    <p id="status" class="status" role="status">正在加载网址…</p>
    <section id="directory" class="directory" aria-label="网址目录"></section>
  </main>
</body>
</html>

3. 添加样式

新建 styles.css。这里使用响应式网格,窄屏时卡片自动变为单列,不需要额外的 CSS 框架。

:root {
  color-scheme: light;
  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
  color: #202938;
  background: #f5f7fb;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
}

.page {
  width: min(100% - 32px, 1040px);
  margin: 0 auto;
  padding: 56px 0;
}

.header {
  margin-bottom: 28px;
}

.eyebrow {
  color: #5b6fc7;
  font-size: 12px;
  font-weight: 700;
  letter-spacing: .12em;
}

h1 {
  margin: 8px 0;
  font-size: clamp(30px, 6vw, 48px);
}

.header p {
  color: #657084;
}

.tools {
  display: grid;
  grid-template-columns: auto minmax(180px, 1fr) auto minmax(150px, 220px);
  align-items: center;
  gap: 12px;
  margin: 24px 0;
}

input,
select {
  min-height: 42px;
  padding: 8px 12px;
  border: 1px solid #d8deea;
  border-radius: 8px;
  background: #fff;
  font: inherit;
}

.status {
  min-height: 24px;
  color: #657084;
}

.directory {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr));
  gap: 16px;
}

.card {
  min-width: 0;
  padding: 18px;
  border: 1px solid #e1e6ef;
  border-radius: 12px;
  background: #fff;
}

.card h2 {
  margin: 0 0 8px;
  font-size: 18px;
}

.card p {
  color: #657084;
  line-height: 1.6;
}

.card .category {
  color: #5b6fc7;
  font-size: 13px;
}

.card a {
  color: inherit;
  text-decoration: none;
}

.card a:hover,
.card a:focus-visible {
  color: #4058b6;
  text-decoration: underline;
}

@media (max-width: 620px) {
  .page {
    padding-top: 32px;
  }

  .tools {
    grid-template-columns: 1fr;
  }
}

4. 加载、校验和展示数据

新建 app.js。浏览器端校验不是用来替代服务器安全控制,而是避免格式错误、过长数据和不需要的协议进入页面。代码仅创建 DOM 节点并写入 textContent,不使用 innerHTML 拼接外部字符串。

"use strict";

const searchInput = document.querySelector("#search");
const categorySelect = document.querySelector("#category");
const directory = document.querySelector("#directory");
const status = document.querySelector("#status");

const MAX_ITEMS = 300;
const MAX_TITLE = 80;
const MAX_CATEGORY = 40;
const MAX_DESCRIPTION = 200;
const MAX_URL = 2048;

function cleanText(value, maxLength) {
  if (typeof value !== "string") return "";
  return value.trim().slice(0, maxLength);
}

function validUrl(value) {
  const text = cleanText(value, MAX_URL);
  if (!text || value.trim().length > MAX_URL) return null;

  try {
    const parsed = new URL(text);
    if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
      return null;
    }
    if (parsed.username || parsed.password) return null;
    return parsed.href;
  } catch {
    return null;
  }
}

function normalizeItem(item) {
  if (!item || typeof item !== "object" || Array.isArray(item)) return null;

  const title = cleanText(item.title, MAX_TITLE);
  const category = cleanText(item.category, MAX_CATEGORY);
  const description = cleanText(item.description, MAX_DESCRIPTION);
  const url = typeof item.url === "string" ? validUrl(item.url) : null;

  if (!title || !category || !url) return null;
  return { title, category, description, url };
}

function render(items) {
  const query = searchInput.value.trim().toLocaleLowerCase();
  const selectedCategory = categorySelect.value;

  const filtered = items.filter((item) => {
    const matchesCategory =
      !selectedCategory || item.category === selectedCategory;
    const text = `${item.title} ${item.category} ${item.description}`
      .toLocaleLowerCase();
    return matchesCategory && text.includes(query);
  });

  directory.replaceChildren();

  for (const item of filtered) {
    const card = document.createElement("article");
    card.className = "card";

    const heading = document.createElement("h2");
    const link = document.createElement("a");
    link.href = item.url;
    link.target = "_blank";
    link.rel = "noopener noreferrer";
    link.textContent = item.title;
    heading.append(link);

    const category = document.createElement("p");
    category.className = "category";
    category.textContent = item.category;

    const description = document.createElement("p");
    description.textContent = item.description;

    card.append(heading, category, description);
    directory.append(card);
  }

  status.textContent = `显示 ${filtered.length} 个网址`;
}

async function start() {
  try {
    const response = await fetch("/sites.json", { cache: "no-cache" });
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }

    const data = await response.json();
    if (!Array.isArray(data)) {
      throw new Error("网址数据必须是 JSON 数组");
    }

    const items = data
      .slice(0, MAX_ITEMS)
      .map(normalizeItem)
      .filter(Boolean);

    const categories = [...new Set(items.map((item) => item.category))].sort();
    for (const name of categories) {
      const option = document.createElement("option");
      option.value = name;
      option.textContent = name;
      categorySelect.append(option);
    }

    render(items);
    if (items.length < data.length) {
      status.textContent += `;最多读取前 ${MAX_ITEMS} 项`;
    }
  } catch (error) {
    console.error("无法加载网址目录:", error);
    status.textContent = "网址目录加载失败,请检查 sites.json 和服务器配置。";
  }
}

searchInput.addEventListener("input", () => {
  if (Array.isArray(window.navItems)) render(window.navItems);
});

categorySelect.addEventListener("change", () => {
  if (Array.isArray(window.navItems)) render(window.navItems);
});

async function loadDirectory() {
  try {
    const response = await fetch("/sites.json", { cache: "no-cache" });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);

    const data = await response.json();
    if (!Array.isArray(data)) throw new Error("数据不是数组");

    window.navItems = data
      .slice(0, MAX_ITEMS)
      .map(normalizeItem)
      .filter(Boolean);

    const categories = [...new Set(window.navItems.map((item) => item.category))].sort();
    for (const name of categories) {
      const option = document.createElement("option");
      option.value = name;
      option.textContent = name;
      categorySelect.append(option);
    }

    render(window.navItems);
    if (window.navItems.length < data.length) {
      status.textContent += `;最多读取前 ${MAX_ITEMS} 项`;
    }
  } catch (error) {
    console.error("无法加载网址目录:", error);
    status.textContent = "网址目录加载失败,请检查 sites.json 和服务器配置。";
  }
}

searchInput.addEventListener("input", () => render(window.navItems || []));
categorySelect.addEventListener("change", () => render(window.navItems || []));
loadDirectory();

上面的示例应删除 start() 函数和它之前的两组事件监听,只保留末尾的 loadDirectory() 实现及最后两组监听。为避免复制时产生重复逻辑,更推荐直接采用下面这个整理规则:文件中只保留一个加载函数、一组事件监听。也可以将前面的代码中从 async function start() 开始的部分整体替换为末尾的 loadDirectory() 版本。

如果希望避免上述替换步骤,以下是 app.js 中必须保留的完整尾部逻辑:也就是说,实际文件中不要重复声明 searchInput、categorySelect 等变量,加载逻辑只执行一次。完成后搜索框会匹配名称、分类和说明,分类框会根据有效数据动态生成选项。

校验的安全边界:只允许 HTTP/HTTPS,可以排除 javascript:、data: 等不适合直接作为导航目标的协议;拒绝 URL 中的用户名和密码,可避免展示类似 https://user:[email protected]/ 的链接。它不能判断一个 HTTP/HTTPS 网站是否可信,也不能阻止钓鱼站、恶意下载或跳转链接。链接的最终目的地还可能在目标网站一侧变化,维护者应审核来源并定期复查。

此外,点击外部链接前,浏览器地址栏显示的域名仍是判断目的地的重要依据。rel="noopener noreferrer" 用于新标签页链接:noopener 防止新页面通过 window.opener 操作来源页,noreferrer 会抑制来源页地址作为 Referer 发送;它们不是对目标网站的信誉背书。

三、本地检查和调试

不要直接双击 index.html 作为正式测试方式。页面使用 fetch("/sites.json") 请求数据,本地文件地址可能受到浏览器的源策略限制。可用 Python 自带的静态服务器从项目目录启动临时服务:

cd my-nav
python3 -m http.server 8000

然后打开 http://localhost:8000/。如果电脑没有 Python,也可以用其他静态文件服务器。这个临时服务器适合本机检查,不应直接作为面向公网的生产服务。

检查以下内容:

  • 页面打开后是否显示网址卡片,搜索和分类筛选是否工作。
  • 在开发者工具的 Network 面板中,/sites.json 是否返回 200,响应是否为合法 JSON。
  • 控制台是否有 JavaScript 错误;若目录为空,检查文件名大小写、JSON 逗号和访问路径。
  • 故意把某条 URL 改成 javascript:alert(1),该条目应被忽略,而不是生成可点击链接。

四、使用 Nginx 和 Docker 部署

容器部署的好处是可以把文件和 Web 服务配置一同交付。这里的 Nginx 只提供静态文件,不负责 TLS 证书签发,也不提供用户认证。

新建 nginx.conf:

server {
    listen 80;
    server_name _;

    root /usr/share/nginx/html;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }

    location = /sites.json {
        add_header Cache-Control "no-cache";
        try_files $uri =404;
    }

    location ~* \.(css|js)$ {
        add_header Cache-Control "no-cache";
        try_files $uri =404;
    }
}

try_files 在文件不存在时返回 404,避免把所有路径都错误地返回主页。示例对数据、CSS 和 JS 设置不缓存或重新验证的策略,便于维护者更新目录后尽快看到变化。若改用带文件名哈希的构建产物,可对版本化静态资源设置长期缓存;不要在不了解缓存行为的情况下给可变的 sites.json 设置很长的强缓存时间。

新建 Dockerfile:

FROM nginx:stable-alpine

COPY index.html styles.css app.js sites.json /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80

这里使用官方 Nginx 镜像作为运行环境。EXPOSE 80 是镜像元数据,表示容器预期使用的端口,并不会自动把端口发布到宿主机;端口映射要在运行命令或 Compose 配置中指定。

在项目目录构建并启动:

docker build -t my-nav:1.0 .
docker run -d --name my-nav --restart unless-stopped -p 8080:80 my-nav:1.0

此时可在部署机器上访问 http://服务器地址:8080/。若部署机器的防火墙或云平台安全组没有放行相应端口,外部访问仍会失败。将 8080 换成其他宿主机端口时,冒号右侧的容器端口仍应是 80,例如 -p 9000:80。

也可以新建 compose.yaml,用 Compose 管理容器:

services:
  nav:
    build: .
    container_name: my-nav
    restart: unless-stopped
    ports:
      - "8080:80"

在文件所在目录执行 docker compose up -d --build 启动;修改源码后再次执行该命令重建并更新。要查看错误,可运行 docker compose logs。不同服务器的 Docker、Compose 版本和安装方式可能不同,命令选项应以当前版本的文档为准。

五、配置自己的域名和 HTTPS

域名绑定一般分为四步:

  1. 准备域名:在注册商处拥有一个域名,并确认 DNS 管理入口。
  2. 配置 DNS:为域名或子域名添加解析记录。常见做法是用 A 记录指向服务器 IPv4 地址;有可用 IPv6 时可配置 AAAA 记录。CNAME 用于把名称指向另一个主机名,通常不能与同一主机名下的其他记录随意混用。具体规则以 DNS 服务商为准。
  3. 开放网络入口:确保服务器、防火墙和云安全组允许所需的入站端口。域名解析成功不代表网络端口已经开放。
  4. 配置 Web 服务与证书:Nginx 应匹配对应的 server_name,并部署有效证书,让 HTTPS 请求到达正确的站点。

DNS 解析到服务器后,仍需有服务监听相应端口。生产环境常见做法是在宿主机或独立入口服务上运行反向代理,由它监听 80/443、处理证书和 HTTPS,再把请求转发到导航容器的 80 端口。另一种方式是使用提供 HTTPS 的静态托管平台。选择哪种方式取决于你的基础设施,不存在仅修改 sites.json 就能自动启用 HTTPS 的通用步骤。

如果使用反向代理,确认代理目标、域名和证书覆盖的名称一致。例如证书只覆盖 nav.example.com,并不当然覆盖 example.com。证书申请与自动续期需要按照所用平台或证书工具配置;证书过期、域名解析错误、代理没有监听 443,都会导致 HTTPS 访问失败。不要为了绕过证书错误而要求访客忽略浏览器警告。

本文示例的 Nginx 容器只监听容器内的 80 端口,没有配置 HTTPS。若将容器端口直接发布到公网,它提供的是明文 HTTP。通过反向代理对外提供 HTTPS 时,还应确认代理到容器之间的网络暴露范围符合预期;不需要公网直连时,不要额外开放容器服务端口。

六、更新目录和部署版本

增加网址时,在 sites.json 数组中添加对象;删除时删除对应对象;调整分类只需修改 category。字段名称要与代码一致:title、url、category 和 description。标题或分类为空、URL 无效的条目会被忽略。

若使用 Docker 镜像部署,修改本地 JSON 文件不会自动改变已经构建好的镜像,需要重新构建并替换容器。生产环境宜把源码存入版本控制,先在本地检查,再通过 CI 构建镜像并发布;不要把服务器密码、访问令牌、私钥或任何秘密信息写进公开的 JSON、HTML 或 JavaScript。浏览器收到的前端文件和数据都可被任何访问者查看。

如果采用静态托管平台,通常将代码仓库连接到平台并设置发布目录即可,但具体入口、构建命令、域名绑定和证书策略因平台而异。当前示例没有构建步骤,发布目录就是项目中的静态文件所在目录。部署后要确认托管平台没有把路径前缀、缓存规则或访问控制配置成与页面请求不一致。

七、常见错误及排查顺序

页面能打开,但没有任何卡片

先查看 /sites.json 是否返回成功,再检查响应内容是否为合法 JSON。随后检查数据中的 URL 是否以 HTTP 或 HTTPS 开头,字段名是否拼写正确。若服务器使用子路径发布,代码中的 /sites.json 是从域名根路径请求;站点如果位于 /nav/ 这样的子目录,就要相应调整页面资源路径和数据请求路径。

本地正常,部署后 JavaScript 或 CSS 404

检查文件是否被复制到 Nginx 根目录、大小写是否一致,以及部署平台的发布目录设置。Linux 文件系统通常区分大小写,App.js 与 app.js 可能不是同一个文件。也要检查反向代理是否改写了路径。

浏览器显示旧目录

分别检查浏览器缓存、CDN 缓存和反向代理缓存。本文 Nginx 配置为数据文件设置 no-cache,含义是使用前应重新验证缓存,并非完全禁止缓存。CDN 仍可能有自己的缓存规则;必要时通过平台提供的刷新功能清理对应资源,并核对实际响应头。

域名无法访问,但服务器本机可以访问

依次检查 DNS 记录是否生效且指向正确地址、防火墙和云安全组是否放行、服务是否监听预期端口、反向代理是否配置正确。DNS 变更的生效时间受缓存影响,不能仅凭本机解析结果判断所有网络都已更新。

HTTPS 报证书错误或跳回 HTTP

核对证书是否包含正在访问的主机名、证书链是否完整、有效期是否正常,并检查代理的 HTTPS 终止和跳转规则。若 CDN 或代理处于 HTTPS 之后,还要按其产品文档配置源站连接模式,避免配置不一致形成重定向循环。

链接在新标签页打开,但无法判断是否可信

导航站只能提供链接,不能替目标网站做安全认证。应由维护者核对域名拼写和来源;公开站点可考虑标注链接用途,并及时删除失效或可疑条目。不要根据网页标题、HTTPS 锁标志或本站校验通过就推断目标网站一定安全。

八、上线前检查清单

  • 确认所有公开链接均为预期的完整地址,分类和说明没有泄露不该公开的信息。
  • 确认 JSON 合法,页面在窄屏和桌面浏览器中都可操作,搜索与分类筛选正常。
  • 确认无效协议和含凭据的 URL 会被拒绝,用户提供的文本没有通过 innerHTML 插入页面。
  • 确认 DNS 指向正确服务器,入站端口、反向代理、证书和自动续期安排符合部署环境要求。
  • 确认数据、页面脚本和服务器访问日志中没有密码、令牌或私钥。
  • 确认更新发布、回滚和缓存刷新流程可执行,并留有源码版本记录。

静态导航站的优势是组成简单、资源消耗低、部署方式灵活。把链接数据独立维护、把输入当作不可信文本处理,并清楚区分“页面能展示链接”和“目标服务受到保护”,就能避免不少常见误区。后续若需要在线编辑、用户权限或访问统计,再根据实际需求增加后端,而不是把静态目录误当成具备这些能力的应用。

© 版权声明

相关文章