guideExpress

本指南介绍了如何在 Express 应用程序中集成 CKBox。如果您希望直接跳转到代码,可以在 GitHub 上找到本指南中描述的 应用程序的完整代码

# 先决条件

在开始之前,请确保您已在系统中安装了最新版本的 Node.js LTS。如果无法从命令行访问该工具,请先按照 Node.js 下载说明进行操作。

# 创建应用程序

在我们的示例中,我们将使用 Express 项目生成器 创建的项目作为基础。首先,让我们为应用程序创建一个目录,进入该目录,并使用以下命令

npx express-generator --view=ejs --git

项目创建完成后,更新依赖项列表并安装软件包

npx ncu -u && npm install

最后,我们可以从模板中删除 routes/users.jspublic/stylesheets/style.css 文件,因为我们不会使用它们。

# 环境变量

我们正在构建的应用程序依赖于一些变量,这些变量最好保密。因此,在继续下一步之前,让我们在项目的根目录中创建一个 .env 文件。在本指南中,我们将在此添加我们的环境变量。

让我们安装 dotenv 软件包,它将帮助我们在项目中使用 .env 文件

npm add dotenv

# 身份验证

在典型情况下,对 CKBox 的访问将仅限于 已认证 的用户。因此,我们将借助 Passport 在我们的应用程序中引入一个简单的身份验证机制。让我们从安装必要的依赖项开始。为了简单起见,我们在应用程序中省略了 CSRF 保护措施。请注意,此示例使用了一种简化的方法,不应在生产应用程序中使用。

npm add passport passport-local express-session connect-ensure-login

然后,让我们创建一个 routes/auth.js 路由器,它将负责用户身份验证。我们将使用 3 个测试用户,每个用户将被分配不同的 CKBox 角色:useradminsuperadmin。Passport 的方法 passport.serializeUser 将确保用户的资料(包括他们在 CKBox 中的角色)将可用于其他路由处理程序。

以下是在身份验证路由器中处理程序的完整代码。

// routes/auth.js

const express = require("express");
const passport = require("passport");
const LocalStrategy = require("passport-local");

const dbUsers = [
    {
        id: "1",
        role: "user",
        email: "user@acme.com",
        password: "testpwd123",
        name: "John",
    },
    {
        id: "2",
        role: "admin",
        email: "admin@acme.com",
        password: "testpwd123",
        name: "Joe",
    },
    {
        id: "3",
        role: "superadmin",
        email: "superadmin@acme.com",
        password: "testpwd123",
        name: "Alice",
    },
];

passport.use(
    new LocalStrategy({ usernameField: "email" }, function (email, password, done) {
        const dbUser = dbUsers.find(function (dbUser) {
            return dbUser.email === email && dbUser.password === password;
        });

        if (!dbUser) {
            return done(null, false);
        }

        return done(null, dbUser);
    })
);

passport.serializeUser(function (user, cb) {
    process.nextTick(function () {
        cb(null, { id: user.id, name: user.name, role: user.role });
    });
});

passport.deserializeUser(function (user, cb) {
    process.nextTick(function () {
        return cb(null, user);
    });
});

const router = express.Router();

router.get("/login", function (req, res, next) {
    res.render("login");
});

router.post(
    "/login/password",
    passport.authenticate("local", {
        successReturnToOrRedirect: "/",
        failureRedirect: "/login",
        failureMessage: true,
    })
);

router.post("/logout", function (req, res, next) {
    req.logout(function (err) {
        if (err) {
            return next(err);
        }

        res.redirect("/");
    });
});

module.exports = router;

# 令牌 URL

CKBox 与其他 CKEditor 云服务一样,使用 JWT 令牌进行身份验证和授权。所有这些令牌都在您的应用程序端生成,并使用您可以在 CKEditor 生态系统仪表板 中获得的共享密钥进行签名。有关如何创建访问凭据的信息,请参阅 创建访问凭据 文章,该文章位于 身份验证指南 中。

现在我们有了必要的访问凭据,即环境 ID 和访问密钥,让我们创建令牌端点。作为基础,我们将使用 Node.js 中的通用令牌端点 的代码。

首先,让我们安装 jsonwebtoken 库,用于创建 JWT 令牌

npm add jsonwebtoken

让我们用 Express 路由处理程序包装 Node.js 令牌端点的逻辑,并使用 CKBox 所需的有效负载 生成一个令牌。请注意,对该端点的访问仅限于已认证的用户。

让我们创建一个 Express 路由器,它将处理与 CKBox 相关的所有请求。我们将依靠由 Passport 为已认证用户设置的 req.user 对象。

// routes/ckbox.js

const express = require("express");
const jwt = require("jsonwebtoken");

const CKBOX_ENVIRONMENT_ID = process.env.CKBOX_ENVIRONMENT_ID;
const CKBOX_ACCESS_KEY = process.env.CKBOX_ACCESS_KEY;

const router = express.Router();

router.get("/api/ckbox", function (req, res, next) {
    const user = req.user;

    if (user && user.role) {
        const payload = {
            aud: CKBOX_ENVIRONMENT_ID,
            sub: user.id,
            auth: {
                ckbox: {
                    role: user.role,
                },
            },
        };

        const result = jwt.sign(payload, CKBOX_ACCESS_KEY, {
            algorithm: "HS256",
            expiresIn: "1h",
        });

        res.send(result);
    } else {
        next({ message: "Unauthenticated user", status: 401 });
    }
});

module.exports = router;

如您在上面的代码清单中所见,签署 JWT 令牌所需的访问凭据是从环境变量中获取的。感谢这一点,您可以方便地将它们添加到 .env 文件中

# .env
CKBOX_ENVIRONMENT_ID=REPLACE-WITH-ENVIRONMENT-ID
CKBOX_ACCESS_KEY=REPLACE-WITH-ACCESS-KEY

# 视图

现在,让我们构建几个视图。我们将使用 Tailwind CSS 来使我们的示例应用程序更具吸引力。为了简单起见,我们将使用 Play CDN 直接在浏览器中使用 Tailwind,无需任何构建步骤。请注意,这种方法 不是生产环境的最佳选择。

# 登录视图

让我们从在 views/login.ejs 下创建一个简单的登录视图开始。用户将提供他们的电子邮件地址和密码,我们将使用之前创建的 /login/password 处理程序来验证他们。

<!-- views/login.ejs -->

<!doctype html>
<html lang="en">
    <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <script src="https://cdn.tailwindcss.com"></script>
        <title>Login</title>
    </head>
    <body>
        <section class="flex justify-center items-center h-screen">
            <form
                action="/login/password"
                method="post"
                class="bg-blue-500 text-center w-1/2 xl:w-1/3 px-3 py-4 rounded flex flex-col gap-2"
            >
                <section class="flex flex-col gap-1">
                    <label for="email" class="text-left text-white">Email</label>
                    <input
                        id="email"
                        name="email"
                        type="text"
                        required
                        autofocus
                        class="block w-full mx-auto text-sm py-2 px-3 rounded"
                    />
                </section>
                <section class="flex flex-col gap-1">
                    <label for="password" class="text-left text-white">Password</label>
                    <input
                        id="current-password"
                        name="password"
                        type="password"
                        required
                        class="block w-full mx-auto text-sm py-2 px-3 rounded my-3"
                    />
                </section>
                <button
                    type="submit"
                    class="bg-blue text-white font-bold py-2 px-4 rounded border block mx-auto w-full"
                >
                    Login
                </button>
            </form>
        </section>
    </body>
</html>

# CKBox 与 CKEditor

views/ckeditor.ejs 视图中,我们将使用运行 CKEditor 5 的最快捷、最简单的方法 - 从 CDN 提供服务。有关更高级的集成方案,请参阅 CKEditor 5 文档

<!-- views/ckeditor.ejs -->

<!doctype html>
<html lang="en">
    <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <script src="https://cdn.tailwindcss.com"></script>
        <script src="https://cdn.ckbox.io/ckbox/2.5.1/ckbox.js"></script>
        <script src="https://cdn.ckeditor.com/ckeditor5/42.0.0/classic/ckeditor.js"></script>
        <link
            rel="stylesheet"
            href="https://cdn.ckbox.io/ckbox/2.5.1/styles/themes/lark.css"
        />
        <link rel="stylesheet" href="https://cdn.ckeditor.com/ckeditor5/42.0.0/ckeditor5.css" />
        <title>CKEditor</title>
        <style>
            .ck-editor__editable_inline {
                min-height: 400px;
            }
        </style>
    </head>
    <body>
        <div class="mx-auto h-screen flex flex-col">
            <%- include('nav'); %>
            <div class="px-8 flex-1 bg-accents-0">
                <main class="w-full max-w-4xl mx-auto py-16 h-full flex flex-col">
                    <section class="flex flex-col gap-6">
                        <h2 class="text-4xl font-semibold tracking-tight">CKEditor</h2>
                    </section>
                    <hr class="border-t border-accents-2 my-6" />
                    <section class="flex flex-col gap-3 h-4/5">
                        In this example CKBox is integrated with CKEditor. With CKBox plugin,
                        CKEditor will upload files directly to your CKBox environment. Use icon in
                        the top-left corner of the editor to open CKBox as a file picker.
                        <div id="editor"></div>
                    </section>
                </main>
            </div>
        </div>

        <script type="importmap">
            {
                "imports": {
                    "ckeditor5": "https://cdn.ckeditor.com/ckeditor5/42.0.0/ckeditor5.js",
                    "ckeditor5/": "https://cdn.ckeditor.com/ckeditor5/42.0.0/"
                }
            }
        </script>

        <script type="module">
            import {
                ClassicEditor,
                CKBox,
                Essentials,
                Bold,
                Italic,
                Font,
                Paragraph
            } from 'ckeditor5';

            ClassicEditor
                .create( document.querySelector( '#editor' ), {
                    plugins: [ CKBox, Essentials, Bold, Italic, Font, Paragraph ],
                    ckbox: {
                        tokenUrl: "<%= tokenUrl %>",
                        theme: 'lark'
                    },
                    toolbar: [
                        'ckbox', '|', 'undo', 'redo', '|', 'bold', 'italic', '|',
                        'fontSize', 'fontFamily', 'fontColor', 'fontBackgroundColor'
                    ],
                } )
                .catch( error => {
                    console.error( error );
                } );
        </script>
    </body>
</html>

正如您所注意到的,该视图依赖于 nav.ejs 部分,这是一个可重复使用的导航栏,它将位于我们应用程序中的大多数视图中。因此,让我们添加 views/nav.ejs 文件。

<!-- views/nav.ejs -->

<nav
    class="border-b border-gray-200 py-5 relative z-20 bg-background shadow-[0_0_15px_0_rgb(0,0,0,0.1)]"
>
    <div class="flex items-center lg:px-6 px-8 mx-auto max-w-7xl">
        <div class="flex-1 hidden md:flex">
            <a href="/" class="text-blue-600 hover:text-blue-800 visited:text-purple-600">Home</a>
        </div>
        <div class="flex-1 justify-end flex items-center md:flex gap-3 h-8">
            <% if (name) { %>
            <span>Welcome, <%= name %>!</span>
            <form action="/logout" method="post">
                <button
                    type="submit"
                    class="text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-1.5"
                >
                    Sign out
                </button>
            </form>
            <% } else { %>
            <a
                href="/login"
                class="text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-1.5"
                >Sign in</a
            >
            <% } %>
            <a
                href="https://github.com/ckbox-io/ckbox-express-example"
                target="_blank"
                rel="noreferrer"
                class="text-blue-600 hover:text-blue-800 visited:text-purple-600"
            >
                GitHub
            </a>
        </div>
    </div>
</nav>

然后,让我们在 routes/ckbox.js 文件中声明该视图的路由。

// routes/ckbox.js

// ...

const ensureLoggedIn = require("connect-ensure-login").ensureLoggedIn;
const PUBLIC_URL = process.env.PUBLIC_URL;

router.get("/ckeditor", ensureLoggedIn(), function (req, res) {
    res.render("ckeditor", {
        name: req.user.name,
        tokenUrl: `${PUBLIC_URL}/api/ckbox`,
    });
});

// ...

最后,让我们借此机会将 PUBLIC_URL=https://127.0.0.1:3000 条目添加到 .env 文件中。

# CKBox 作为文件选择器

常见的场景之一是将 CKBox 用作文件选择器,用户可以在其中选择文件管理器中存储的文件之一。选择文件后,我们希望获得有关所选文件的详细信息,尤其是它们的 URL。这可以通过使用作为 CKBox 配置选项传递的 assets.onChoose 回调来实现。

以下是 views/file-picker.ejs 视图的代码片段。

<!-- views/file-picker.ejs -->

<!doctype html>
<html lang="en">
    <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <script src="https://cdn.tailwindcss.com"></script>
        <script src="https://cdn.ckbox.io/ckbox/2.5.1/ckbox.js"></script>
        <title>File picker</title>
    </head>
    <body>
        <div class="mx-auto h-screen flex flex-col">
            <%- include('nav'); %>
            <div class="px-8 flex-1 bg-accents-0">
                <main class="w-full max-w-4xl mx-auto py-16 h-full flex flex-col">
                    <section class="flex flex-col gap-6">
                        <h2 class="text-4xl font-semibold tracking-tight">File Picker</h2>
                    </section>
                    <hr class="border-t border-accents-2 my-6" />
                    <section class="flex flex-col gap-3">
                        One of the common scenarios is to use CKBox as a file picker, where the user
                        can choose one of the files stored in the file manager. After choosing the
                        file, we want to obtain information about the chosen files, especially their
                        URLs.
                        <div>
                            <button
                                id="choose-assets"
                                class="text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-1.5"
                            >
                                Choose assets
                            </button>
                        </div>
                        <div id="ckbox"></div>
                    </section>
                    <section class="flex flex-col gap-3">
                        <ul id="assets-list"></ul>
                    </section>
                </main>
            </div>
        </div>
        <script>
            const chooseBtn = document.getElementById("choose-assets");
            const assetsList = document.getElementById("assets-list");

            const handleChoose = (assets) => {
                assetsList.innerHTML = "";

                const name = assets.forEach(({ data }) => {
                    const item = document.createElement("li");
                    const name = document.createElement("span");

                    name.textContent = `${data.name}.${data.extension}`;

                    if (data.url) {
                        const link = document.createElement("a");
                        const linkClasses = [
                            "text-blue-600",
                            "hover:text-blue-800",
                            "visited:text-purple-600",
                        ];

                        link.classList.add(...linkClasses);
                        link.setAttribute("href", data.url);
                        link.setAttribute("target", "_blank");
                        link.setAttribute("rel", "noreferrer");

                        link.appendChild(name);
                        item.appendChild(link);
                    } else {
                        item.appendChild(name);
                    }

                    assetsList.appendChild(item);
                });
            };

            chooseBtn.addEventListener("click", () => {
                CKBox.mount(document.getElementById("ckbox"), {
                    tokenUrl: "<%= tokenUrl %>",
                    dialog: true,
                    assets: { onChoose: handleChoose },
                });
            });
        </script>
    </body>
</html>

然后,让我们在 routes/ckbox.js 文件中声明该视图的路由。

// routes/ckbox.js

// ...

router.get("/file-picker", ensureLoggedIn(), function (req, res) {
    res.render("file-picker", {
        name: req.user.name,
        tokenUrl: `${PUBLIC_URL}/api/ckbox`,
    });
});

// ...

# CKBox 内联模式

要在内联模式下初始化 CKBox,您只需将其挂载到应用程序的所需位置即可。这是 CKBox 的默认嵌入模式,因此除了 tokenUrl 之外,不需要额外的配置属性。CKBox 将占用其父元素允许的尽可能多的空间。

以下是 views/inline.ejs 视图的代码片段。

<!-- views/inline.ejs -->

<!doctype html>
<html lang="en">
    <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <script src="https://cdn.tailwindcss.com"></script>
        <script src="https://cdn.ckbox.io/ckbox/2.5.1/ckbox.js"></script>
        <title>Inline</title>
    </head>
    <body>
        <div class="mx-auto h-screen flex flex-col">
            <%- include('nav'); %>
            <div class="px-8 flex-1 bg-accents-0">
                <main class="w-full max-w-4xl mx-auto py-16 h-full flex flex-col">
                    <section class="flex flex-col gap-6">
                        <h2 class="text-4xl font-semibold tracking-tight">Inline</h2>
                    </section>
                    <hr class="border-t border-accents-2 my-6" />
                    <section class="flex flex-col gap-3 flex-1">
                        <p>
                            To start CKBox in inline mode, you can instantiate it in an arbitrary
                            container. CKBox will respect height and width of the container.
                        </p>
                        <div id="ckbox" class="h-full"></div>
                    </section>
                </main>
            </div>
        </div>
        <script>
            CKBox.mount(document.getElementById("ckbox"), {
                tokenUrl: "<%= tokenUrl %>",
            });
        </script>
    </body>
</html>

然后,让我们在 routes/ckbox.js 文件中声明该视图的路由。

// routes/ckbox.js

// ...

router.get("/inline", ensureLoggedIn(), function (req, res) {
    res.render("inline", {
        name: req.user.name,
        tokenUrl: `${PUBLIC_URL}/api/ckbox`,
    });
});

// ...

# 主页

最后,让我们调整主页,使其显示指向上面创建的所有视图的链接。因此,让我们用下面概述的代码片段替换初始 views/index.ejs 文件的内容。

<!-- views/index.ejs -->

<!doctype html>
<html lang="en">
    <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <script src="https://cdn.tailwindcss.com"></script>
        <title>Express</title>
    </head>
    <body>
        <div class="mx-auto h-screen flex flex-col">
            <%- include('nav'); %>
            <div class="px-8 flex-1 bg-accents-0">
                <main class="w-full max-w-4xl mx-auto py-16 h-full flex flex-col">
                    <section class="flex flex-col gap-6">
                        <h2 class="text-3xl font-semibold tracking-tight">
                            CKBox integration with Express
                        </h2>
                    </section>
                    <hr class="border-t border-accents-2 my-6" />
                    <section class="flex flex-col gap-3">
                        <p>Below you can find example integrations of CKBox.</p>
                        <p>
                            In a typical scenario access to CKBox will be restricted to
                            authenticated users only. Therefore, each sample is restricted to signed
                            in users only. Use different credentials to unlock various CKBox roles.
                            See available users
                            <a
                                href="https://github.com/ckbox-io/ckbox-express-example/blob/main/routes/auth.js"
                                target="_blank"
                                rel="noreferrer"
                                class="text-blue-600 hover:text-blue-800"
                                >here</a
                            >.
                        </p>
                        <div class="flex-1 hidden md:flex gap-2">
                            <ol>
                                <li>
                                    <a class="text-blue-600 hover:text-blue-800" href="/inline"
                                        >Inline mode</a
                                    >
                                </li>
                                <li>
                                    <a class="text-blue-600 hover:text-blue-800" href="/file-picker"
                                        >File picker</a
                                    >
                                </li>
                                <li>
                                    <a class="text-blue-600 hover:text-blue-800" href="/ckeditor"
                                        >CKEditor</a
                                    >
                                </li>
                            </ol>
                        </div>
                    </section>
                </main>
            </div>
        </div>
    </body>
</html>

此外,让我们通过用以下代码片段替换 routes/index.js 文件的内容来调整索引路由器

const express = require("express");
const router = express.Router();

router.get("/", function (req, res) {
    res.render("index", { name: req.user?.name });
});

module.exports = router;

# 应用程序

最后,让我们将所有路由与主 app.js 文件组合起来。用以下代码片段替换该文件的内容。

const createError = require("http-errors");
const express = require("express");
const path = require("path");
const session = require("express-session");
const cookieParser = require("cookie-parser");
const logger = require("morgan");
const passport = require("passport");

require("dotenv").config();

const indexRouter = require("./routes/index");
const ckboxRouter = require("./routes/ckbox");
const authRouter = require("./routes/auth");

const app = express();

app.set("views", path.join(__dirname, "views"));
app.set("view engine", "ejs");

app.use(logger("dev"));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, "public")));

app.use(
    session({
        secret: process.env.SESSION_SECRET,
        resave: false,
        saveUninitialized: true,
    })
);
app.use(passport.authenticate("session"));

app.use("/", indexRouter);
app.use("/", authRouter);
app.use("/", ckboxRouter);

app.use(function (req, res, next) {
    next(createError(404));
});

app.use(function (err, req, res, next) {
    res.locals.message = err.message;
    res.locals.error = req.app.get("env") === "development" ? err : {};
    res.status(err.status || 500);
    res.render("error");
});

module.exports = app;

使用 express-session 软件包建立的用户会话存储在内存中。因此,您必须在每次启动 Express 应用程序时再次登录。

如您所见,我们使用了一个额外的环境变量:SESSION_SECRET。它的值由 express-session 软件包用于签署会话 ID cookie,最好是一个随机的字符集。以下是 .env 文件的完整内容。

# .env
CKBOX_ENVIRONMENT_ID=REPLACE-WITH-ENVIRONMENT-ID
CKBOX_ACCESS_KEY=REPLACE-WITH-ACCESS-KEY
PUBLIC_URL=https://127.0.0.1:3000
SESSION_SECRET=KdTwwFg0zOd3Bp4INE2UmnEogLJK5uma1wdYABaaODs=

# 恭喜

恭喜您完成本指南!您现在可以通过运行以下命令来访问应用程序

npm start

# 完整代码

您可以在 GitHub 上找到本指南中描述的 应用程序的完整代码