Selaa lähdekoodia

Add password protection for shared posts

jerryliao 2 päivää sitten
vanhempi
commit
460a34e004
7 muutettua tiedostoa jossa 368 lisäystä ja 27 poistoa
  1. 67 0
      islands/SharePasswordForm.tsx
  2. 47 23
      islands/TopBar.tsx
  3. 43 3
      routes/[id].tsx
  4. 43 0
      routes/api/share-verify.tsx
  5. 9 1
      routes/api/share.tsx
  6. 153 0
      tests/api/share_test.ts
  7. 6 0
      utils/db.ts

+ 67 - 0
islands/SharePasswordForm.tsx

@@ -0,0 +1,67 @@
+import { useState } from "preact/hooks";
+import Input from "../components/form/Input.tsx";
+import Button from "../components/form/Button.tsx";
+import ThemeToggle from "./ThemeToggle.tsx";
+
+interface SharePasswordFormProps {
+  id: string;
+  title: string;
+}
+
+export default function SharePasswordForm(props: SharePasswordFormProps) {
+  const [password, setPassword] = useState("");
+  const [error, setError] = useState(false);
+
+  const handleSubmit = async () => {
+    if (!password) {
+      setError(true);
+      return;
+    }
+    const resp = await fetch("/api/share-verify", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ id: props.id, password }),
+    });
+    const respJson = await resp.json();
+    if (respJson.success) {
+      location.reload();
+    } else {
+      setError(true);
+    }
+  };
+
+  return (
+    <div className="flex flex-col items-center justify-center h-full">
+      <div className="absolute top-4 right-4">
+        <ThemeToggle />
+      </div>
+      <div className="w-[320px] max-w-[90%]">
+        <h2 className="text-xl font-medium mb-1 text-center">{props.title}</h2>
+        <p className="text-sm text-gray-500 dark:text-gray-400 mb-6 text-center">
+          This post is password protected.
+        </p>
+        <Input
+          type="password"
+          placeholder="Enter password"
+          error={error}
+          value={password}
+          onInput={(e) => {
+            setError(false);
+            setPassword((e.target as HTMLInputElement).value);
+          }}
+          onKeyDown={(e) => {
+            if (e.key === "Enter") handleSubmit();
+          }}
+        />
+        <Button
+          type="button"
+          variant="primary"
+          className="w-full mt-3"
+          onClick={handleSubmit}
+        >
+          View Post
+        </Button>
+      </div>
+    </div>
+  );
+}

+ 47 - 23
islands/TopBar.tsx

@@ -14,7 +14,7 @@ interface TopBarProps {
 }
 }
 
 
 const settingsData: { [key: string]: string } = {};
 const settingsData: { [key: string]: string } = {};
-const shareData: { [key: string]: boolean } = {};
+const shareData: { [key: string]: boolean | string } = {};
 
 
 export default function TopBar(props: TopBarProps) {
 export default function TopBar(props: TopBarProps) {
   const [mode, setMode] = useState(props.allowMode);
   const [mode, setMode] = useState(props.allowMode);
@@ -35,32 +35,54 @@ export default function TopBar(props: TopBarProps) {
 
 
   const showShare = () => {
   const showShare = () => {
     shareData["shared"] = shareData["submittedShared"] !== undefined
     shareData["shared"] = shareData["submittedShared"] !== undefined
-      ? shareData["submittedShared"]
+      ? shareData["submittedShared"] as boolean
       : props.shared;
       : props.shared;
+    shareData["password"] = shareData["submittedPassword"] || "";
     globalThis.$modal?.show(
     globalThis.$modal?.show(
       "Share options",
       "Share options",
-      <div style="display: flex; align-items: center">
-        <Checkbox
-          label="Share to friends"
-          checked={shareData["shared"]}
-          onChange={(e) => {
-            shareData["shared"] = (e.target as HTMLInputElement).checked;
-            (
-              document.querySelector("#shared-button") as HTMLButtonElement
-            ).style.visibility = shareData["shared"] ? "visible" : "hidden";
-          }}
-        />
-        <Button
-          type="button"
-          id="shared-button"
-          className="ml-2"
-          style={`${!shareData["shared"] ? ";visibility: hidden" : ""}`}
-          onClick={async () => {
-            await navigator.clipboard.writeText(location.href.split("?")[0]);
-          }}
+      <div>
+        <div style="display: flex; align-items: center">
+          <Checkbox
+            label="Share to friends"
+            checked={shareData["shared"] as boolean}
+            onChange={(e) => {
+              shareData["shared"] = (e.target as HTMLInputElement).checked;
+              const extraEl = document.querySelector(
+                "#share-extra",
+              ) as HTMLDivElement;
+              extraEl.style.display = shareData["shared"] ? "flex" : "none";
+            }}
+          />
+          <Button
+            type="button"
+            id="shared-button"
+            className="ml-2"
+            style={`${!shareData["shared"] ? ";visibility: hidden" : ""}`}
+            onClick={async () => {
+              await navigator.clipboard.writeText(
+                location.href.split("?")[0],
+              );
+            }}
+          >
+            Copy Link
+          </Button>
+        </div>
+        <div
+          id="share-extra"
+          style={`display: ${
+            shareData["shared"] ? "flex" : "none"
+          }; align-items: center; margin-top: 12px`}
         >
         >
-          Copy Link
-        </Button>
+          <span style="margin-right: 8px; white-space: nowrap">Password</span>
+          <Input
+            type="password"
+            placeholder="Optional"
+            value={shareData["password"] as string}
+            onInput={(e) => {
+              shareData["password"] = (e.target as HTMLInputElement).value;
+            }}
+          />
+        </div>
       </div>,
       </div>,
       [
       [
         {
         {
@@ -72,11 +94,13 @@ export default function TopBar(props: TopBarProps) {
               body: JSON.stringify({
               body: JSON.stringify({
                 id: props.id,
                 id: props.id,
                 shared: shareData["shared"],
                 shared: shareData["shared"],
+                password: shareData["password"] || "",
               }),
               }),
             });
             });
             const respJson = await resp.json();
             const respJson = await resp.json();
             if (respJson.success) {
             if (respJson.success) {
               shareData["submittedShared"] = shareData["shared"];
               shareData["submittedShared"] = shareData["shared"];
+              shareData["submittedPassword"] = shareData["password"];
               globalThis.$modal?.hide();
               globalThis.$modal?.hide();
             }
             }
           },
           },

+ 43 - 3
routes/[id].tsx

@@ -2,10 +2,12 @@ import { Head } from "fresh/runtime";
 import { HttpError } from "fresh";
 import { HttpError } from "fresh";
 import { page, type PageProps } from "fresh";
 import { page, type PageProps } from "fresh";
 import { define } from "utils/state.ts";
 import { define } from "utils/state.ts";
-import { checkToken } from "utils/server.ts";
+import { checkToken, getCryptoString } from "utils/server.ts";
 import { find } from "utils/db.ts";
 import { find } from "utils/db.ts";
+import { getCookies } from "@std/http";
 import TopBar from "../islands/TopBar.tsx";
 import TopBar from "../islands/TopBar.tsx";
 import Editor, { EditorMode } from "../islands/Editor.tsx";
 import Editor, { EditorMode } from "../islands/Editor.tsx";
+import SharePasswordForm from "../islands/SharePasswordForm.tsx";
 import PageContainer from "../components/layout/PageContainer.tsx";
 import PageContainer from "../components/layout/PageContainer.tsx";
 
 
 interface PostProps {
 interface PostProps {
@@ -15,10 +17,11 @@ interface PostProps {
   shared: boolean;
   shared: boolean;
   isLogined: boolean;
   isLogined: boolean;
   allowMode: EditorMode;
   allowMode: EditorMode;
+  passwordRequired: boolean;
 }
 }
 
 
 export const handler = define.handlers({
 export const handler = define.handlers({
-  GET(ctx) {
+  async GET(ctx) {
     const tokenUserId = checkToken(ctx.req);
     const tokenUserId = checkToken(ctx.req);
     const postId = ctx.params.id;
     const postId = ctx.params.id;
     const post = find(
     const post = find(
@@ -26,9 +29,32 @@ export const handler = define.handlers({
       tokenUserId
       tokenUserId
         ? { id: postId, user_id: tokenUserId }
         ? { id: postId, user_id: tokenUserId }
         : { id: postId, shared: 1 },
         : { id: postId, shared: 1 },
-      ["title", "content", "shared"],
+      ["title", "content", "shared", "share_password"],
     );
     );
     if (post.length > 0) {
     if (post.length > 0) {
+      const sharePassword = post[0]["share_password"] as string;
+
+      // Non-owner accessing a password-protected shared post
+      if (!tokenUserId && sharePassword) {
+        const cookies = getCookies(ctx.req.headers);
+        const shareCookie = cookies[`pd-share-${postId}`] || "";
+        const expectedToken = await getCryptoString(
+          postId + sharePassword,
+          "MD5",
+        );
+        if (shareCookie !== expectedToken) {
+          return page({
+            id: postId,
+            isLogined: false,
+            allowMode: EditorMode.Read,
+            title: post[0]["title"] as string,
+            content: "",
+            shared: true,
+            passwordRequired: true,
+          });
+        }
+      }
+
       return page({
       return page({
         id: postId,
         id: postId,
         isLogined: Boolean(tokenUserId),
         isLogined: Boolean(tokenUserId),
@@ -36,6 +62,7 @@ export const handler = define.handlers({
         title: post[0]["title"] as string,
         title: post[0]["title"] as string,
         content: post[0]["content"] as string,
         content: post[0]["content"] as string,
         shared: post[0]["shared"] === 1,
         shared: post[0]["shared"] === 1,
+        passwordRequired: false,
       });
       });
     }
     }
     throw new HttpError(404);
     throw new HttpError(404);
@@ -43,6 +70,19 @@ export const handler = define.handlers({
 });
 });
 
 
 export default function Post(props: PageProps<PostProps>) {
 export default function Post(props: PageProps<PostProps>) {
+  if (props.data.passwordRequired) {
+    return (
+      <>
+        <Head>
+          <title>{props.data.title}</title>
+        </Head>
+        <PageContainer>
+          <SharePasswordForm id={props.data.id} title={props.data.title} />
+        </PageContainer>
+      </>
+    );
+  }
+
   return (
   return (
     <>
     <>
       <Head>
       <Head>

+ 43 - 0
routes/api/share-verify.tsx

@@ -0,0 +1,43 @@
+import {
+  getCryptoString,
+  makeErrorResponse,
+  makeSuccessResponse,
+} from "utils/server.ts";
+import { define } from "utils/state.ts";
+import { find } from "utils/db.ts";
+import { setCookie } from "@std/http";
+
+export const handler = define.handlers({
+  async POST(ctx) {
+    const req = ctx.req;
+    const reqJson = await req.json();
+    const id = reqJson.id;
+    const password = reqJson.password || "";
+
+    if (id && password) {
+      const post = find("Post", { id, shared: 1 }, [
+        "share_password",
+      ]);
+      if (post.length > 0) {
+        const storedHash = post[0]["share_password"] as string;
+        if (storedHash) {
+          const inputHash = await getCryptoString(password, "MD5");
+          if (inputHash === storedHash) {
+            const token = await getCryptoString(
+              id + storedHash,
+              "MD5",
+            );
+            const resp = makeSuccessResponse(true);
+            setCookie(resp.headers, {
+              name: `pd-share-${id}`,
+              value: token,
+              path: "/",
+            });
+            return resp;
+          }
+        }
+      }
+    }
+    return makeErrorResponse();
+  },
+});

+ 9 - 1
routes/api/share.tsx

@@ -1,5 +1,6 @@
 import {
 import {
   checkToken,
   checkToken,
+  getCryptoString,
   makeErrorResponse,
   makeErrorResponse,
   makeSuccessResponse,
   makeSuccessResponse,
 } from "utils/server.ts";
 } from "utils/server.ts";
@@ -12,11 +13,18 @@ export const handler = define.handlers({
     const reqJson = await req.json();
     const reqJson = await req.json();
     const id = reqJson.id;
     const id = reqJson.id;
     const shared = reqJson.shared;
     const shared = reqJson.shared;
+    const password = reqJson.password || "";
     const tokenUserId = checkToken(req);
     const tokenUserId = checkToken(req);
     if (tokenUserId && id) {
     if (tokenUserId && id) {
       const post = find("Post", { id, user_id: tokenUserId });
       const post = find("Post", { id, user_id: tokenUserId });
       if (post.length > 0) {
       if (post.length > 0) {
-        const newPost = update("Post", id, { shared: shared ? 1 : 0 });
+        const hashedPassword = shared && password
+          ? await getCryptoString(password, "MD5")
+          : "";
+        const newPost = update("Post", id, {
+          shared: shared ? 1 : 0,
+          share_password: hashedPassword,
+        });
         if (newPost.length > 0) {
         if (newPost.length > 0) {
           return makeSuccessResponse(true);
           return makeSuccessResponse(true);
         }
         }

+ 153 - 0
tests/api/share_test.ts

@@ -1,6 +1,7 @@
 import { assertEquals } from "@std/assert";
 import { assertEquals } from "@std/assert";
 import { find, insert } from "utils/db.ts";
 import { find, insert } from "utils/db.ts";
 import { getCryptoString } from "utils/server.ts";
 import { getCryptoString } from "utils/server.ts";
+import { getSetCookies } from "@std/http";
 
 
 let testCounter = 0;
 let testCounter = 0;
 
 
@@ -164,3 +165,155 @@ Deno.test("API share - share another user's post returns error", async () => {
     cleanup(dbPath);
     cleanup(dbPath);
   }
   }
 });
 });
+
+Deno.test("API share - share with password stores hashed password", async () => {
+  const dbPath = getTestDbPath();
+  Deno.env.set("POSTDOWN_DB_PATH", dbPath);
+  try {
+    const { userId, token } = await seedUserAndToken(
+      "sharepw@example.com",
+      "password",
+    );
+    insert("Post", {
+      id: "pw-post-1",
+      title: "Password Post",
+      content: "Secret content",
+      user_id: userId,
+      shared: 0,
+    });
+
+    const { handler } = await import("../../routes/api/share.tsx");
+    const ctx = makeCtx("POST", {
+      id: "pw-post-1",
+      shared: true,
+      password: "mypassword",
+    }, `pd-user-token=${token}`);
+    const res = await handler.POST!(ctx as any);
+    const body = await res.json();
+    assertEquals(body.success, true);
+
+    const post = find("Post", { id: "pw-post-1" }, [
+      "shared",
+      "share_password",
+    ]);
+    assertEquals(post[0]["shared"], 1);
+    const expectedHash = await getCryptoString("mypassword", "MD5");
+    assertEquals(post[0]["share_password"], expectedHash);
+  } finally {
+    Deno.env.delete("POSTDOWN_DB_PATH");
+    cleanup(dbPath);
+  }
+});
+
+Deno.test("API share - unshare clears password", async () => {
+  const dbPath = getTestDbPath();
+  Deno.env.set("POSTDOWN_DB_PATH", dbPath);
+  try {
+    const { userId, token } = await seedUserAndToken(
+      "unshpw@example.com",
+      "password",
+    );
+    const hashedPw = await getCryptoString("oldpw", "MD5");
+    insert("Post", {
+      id: "pw-post-2",
+      title: "PW Post",
+      content: "Content",
+      user_id: userId,
+      shared: 1,
+      share_password: hashedPw,
+    });
+
+    const { handler } = await import("../../routes/api/share.tsx");
+    const ctx = makeCtx("POST", {
+      id: "pw-post-2",
+      shared: false,
+    }, `pd-user-token=${token}`);
+    const res = await handler.POST!(ctx as any);
+    const body = await res.json();
+    assertEquals(body.success, true);
+
+    const post = find("Post", { id: "pw-post-2" }, [
+      "shared",
+      "share_password",
+    ]);
+    assertEquals(post[0]["shared"], 0);
+    assertEquals(post[0]["share_password"], "");
+  } finally {
+    Deno.env.delete("POSTDOWN_DB_PATH");
+    cleanup(dbPath);
+  }
+});
+
+Deno.test("API share-verify - correct password returns success and sets cookie", async () => {
+  const dbPath = getTestDbPath();
+  Deno.env.set("POSTDOWN_DB_PATH", dbPath);
+  try {
+    const hashedPw = await getCryptoString("secret123", "MD5");
+    const { userId } = await seedUserAndToken(
+      "verifypw@example.com",
+      "password",
+    );
+    insert("Post", {
+      id: "verify-post-1",
+      title: "Protected",
+      content: "Secret",
+      user_id: userId,
+      shared: 1,
+      share_password: hashedPw,
+    });
+
+    const { handler } = await import("../../routes/api/share-verify.tsx");
+    const ctx = makeCtx("POST", {
+      id: "verify-post-1",
+      password: "secret123",
+    });
+    const res = await handler.POST!(ctx as any);
+    const body = await res.json();
+    assertEquals(body.success, true);
+
+    const setCookies = getSetCookies(res.headers);
+    const shareCookie = setCookies.find((c) =>
+      c.name === "pd-share-verify-post-1"
+    );
+    const expectedToken = await getCryptoString(
+      "verify-post-1" + hashedPw,
+      "MD5",
+    );
+    assertEquals(shareCookie?.value, expectedToken);
+  } finally {
+    Deno.env.delete("POSTDOWN_DB_PATH");
+    cleanup(dbPath);
+  }
+});
+
+Deno.test("API share-verify - wrong password returns error", async () => {
+  const dbPath = getTestDbPath();
+  Deno.env.set("POSTDOWN_DB_PATH", dbPath);
+  try {
+    const hashedPw = await getCryptoString("secret123", "MD5");
+    const { userId } = await seedUserAndToken(
+      "verifywrong@example.com",
+      "password",
+    );
+    insert("Post", {
+      id: "verify-post-2",
+      title: "Protected",
+      content: "Secret",
+      user_id: userId,
+      shared: 1,
+      share_password: hashedPw,
+    });
+
+    const { handler } = await import("../../routes/api/share-verify.tsx");
+    const ctx = makeCtx("POST", {
+      id: "verify-post-2",
+      password: "wrongpassword",
+    });
+    const res = await handler.POST!(ctx as any);
+    const body = await res.json();
+    assertEquals(body.success, false);
+  } finally {
+    Deno.env.delete("POSTDOWN_DB_PATH");
+    cleanup(dbPath);
+  }
+});

+ 6 - 0
utils/db.ts

@@ -39,10 +39,16 @@ function prepareDB(tableName: string, dbPath?: string) {
           title VARCHAR(256),
           title VARCHAR(256),
           content TEXT,
           content TEXT,
           shared BOOLEAN,
           shared BOOLEAN,
+          share_password TEXT DEFAULT '',
           created DATETIME DEFAULT CURRENT_TIMESTAMP,
           created DATETIME DEFAULT CURRENT_TIMESTAMP,
           updated DATETIME
           updated DATETIME
         )
         )
       `);
       `);
+      try {
+        db.exec(`ALTER TABLE post ADD COLUMN share_password TEXT DEFAULT ''`);
+      } catch {
+        // Column already exists
+      }
   }
   }
   return db;
   return db;
 }
 }