feat: password protected page

This commit is contained in:
Tienson Qin
2025-12-28 23:25:17 +08:00
parent 26c903f535
commit e77690b45b
7 changed files with 296 additions and 95 deletions

View File

@@ -22,6 +22,7 @@
"owner_sub TEXT,"
"created_at INTEGER,"
"updated_at INTEGER,"
"password_hash TEXT,"
"PRIMARY KEY (graph_uuid, page_uuid)"
");"))
(let [cols (publish-common/get-sql-rows (publish-common/sql-exec sql "PRAGMA table_info(pages);"))
@@ -31,7 +32,9 @@
(when-not (contains? col-names "page_tags")
(publish-common/sql-exec sql "ALTER TABLE pages ADD COLUMN page_tags TEXT;"))
(when-not (contains? col-names "short_id")
(publish-common/sql-exec sql "ALTER TABLE pages ADD COLUMN short_id TEXT;")))
(publish-common/sql-exec sql "ALTER TABLE pages ADD COLUMN short_id TEXT;"))
(when-not (contains? col-names "password_hash")
(publish-common/sql-exec sql "ALTER TABLE pages ADD COLUMN password_hash TEXT;")))
(let [cols (publish-common/get-sql-rows (publish-common/sql-exec sql "PRAGMA table_info(page_refs);"))
col-names (set (map #(aget % "name") cols))]
(when (seq col-names)
@@ -109,8 +112,9 @@
"owner_sub,"
"created_at,"
"updated_at,"
"short_id"
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
"short_id,"
"password_hash"
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
" ON CONFLICT(graph_uuid, page_uuid) DO UPDATE SET"
" page_uuid=excluded.page_uuid,"
" page_title=excluded.page_title,"
@@ -122,7 +126,8 @@
" r2_key=excluded.r2_key,"
" owner_sub=excluded.owner_sub,"
" updated_at=excluded.updated_at,"
" short_id=excluded.short_id;")
" short_id=excluded.short_id,"
" password_hash=excluded.password_hash;")
(aget body "page_uuid")
(aget body "page_title")
(aget body "page_tags")
@@ -135,7 +140,8 @@
(aget body "owner_sub")
(aget body "created_at")
(aget body "updated_at")
(aget body "short_id"))
(aget body "short_id")
(aget body "password_hash"))
(let [refs (aget body "refs")
tagged-nodes (aget body "tagged_nodes")
graph-uuid (aget body "graph")
@@ -244,6 +250,18 @@
row (first rows)]
(publish-common/json-response {:page (when row (js->clj row :keywordize-keys false))}))
(= (nth parts 4 nil) "password")
(let [rows (publish-common/get-sql-rows
(publish-common/sql-exec sql
(str "SELECT password_hash "
"FROM pages WHERE graph_uuid = ? AND page_uuid = ? LIMIT 1;")
graph-uuid
page-uuid))
row (first rows)]
(if-not row
(publish-common/not-found)
(publish-common/json-response {:password_hash (aget row "password_hash")})))
(= (nth parts 4 nil) "refs")
(let [rows (publish-common/get-sql-rows
(publish-common/sql-exec sql

View File

@@ -339,6 +339,51 @@ a:hover {
letter-spacing: 0.12em;
}
.password-card {
margin: 20px auto 0;
max-width: 460px;
padding: 24px;
border-radius: 18px;
border: 1px solid var(--border);
background: #fff7ee;
box-shadow: 0 14px 24px rgba(217, 125, 63, 0.12);
text-align: center;
}
.password-form {
margin-top: 18px;
display: grid;
gap: 12px;
}
.password-label {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--muted);
}
.password-input {
width: 100%;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(31, 26, 20, 0.2);
font-size: 15px;
font-family: inherit;
}
.password-input:focus {
outline: 2px solid rgba(217, 125, 63, 0.4);
border-color: rgba(217, 125, 63, 0.6);
}
.password-error {
margin: 8px 0 0;
color: #b42318;
font-size: 13px;
font-weight: 600;
}
.not-found {
text-align: center;
padding: 32px 16px 8px;

View File

@@ -1,19 +1,27 @@
import katexPkg from "https://esm.sh/katex@0.16.10?bundle";
// Core CodeMirror pieces
import { EditorState } from "https://esm.sh/@codemirror/state@6";
import {
EditorState,
EditorView,
basicSetup,
defaultHighlightStyle,
lineNumbers,
} from "https://esm.sh/@codemirror/view@6";
// Highlighting
import {
syntaxHighlighting,
javascript,
python,
html,
css,
json,
markdown,
sql,
clojure,
} from "https://esm.sh/@codemirror/basic-setup@0.20.0?bundle";
defaultHighlightStyle,
} from "https://esm.sh/@codemirror/language@6";
// Languages
import { javascript } from "https://esm.sh/@codemirror/lang-javascript@6";
import { python } from "https://esm.sh/@codemirror/lang-python@6";
import { html } from "https://esm.sh/@codemirror/lang-html@6";
import { json } from "https://esm.sh/@codemirror/lang-json@6";
import { markdown } from "https://esm.sh/@codemirror/lang-markdown@6";
import { sql } from "https://esm.sh/@codemirror/lang-sql@6";
import { css } from "https://esm.sh/@codemirror/lang-css@6";
import { clojure } from "https://esm.sh/@nextjournal/lang-clojure";
const katex = katexPkg.default || katexPkg;
@@ -54,6 +62,7 @@ const initPublish = () => {
const codeEl = block.querySelector("code");
const doc = codeEl ? codeEl.textContent : "";
block.textContent = "";
const lang = (block.dataset.lang || "").toLowerCase();
const langExt = (() => {
if (!lang) return null;
@@ -62,24 +71,28 @@ const initPublish = () => {
}
if (["py", "python"].includes(lang)) return python();
if (["html", "htm"].includes(lang)) return html();
if (["css", "scss"].includes(lang)) return css();
if (["json"].includes(lang)) return json();
if (["md", "markdown"].includes(lang)) return markdown();
if (["sql"].includes(lang)) return sql();
if (["css", "scss"].includes(lang)) return css();
if (["clj", "cljc", "cljs", "clojure"].includes(lang)) return clojure();
return null;
})();
const extensions = [
basicSetup,
lineNumbers(),
syntaxHighlighting(defaultHighlightStyle),
EditorView.editable.of(false),
EditorView.lineWrapping,
];
if (langExt) extensions.push(langExt);
const state = EditorState.create({
doc,
extensions,
});
new EditorView({ state, parent: block });
});
};

View File

@@ -696,6 +696,37 @@
[:p.tag-sub "This page hasn't been published yet."]]]]]
(str "<!doctype html>" (render-hiccup doc))))
(defn render-password-html
[graph-uuid page-uuid wrong?]
(let [title "Protected page"
doc [:html
[:head
[:meta {:charset "utf-8"}]
[:meta {:name "viewport" :content "width=device-width,initial-scale=1"}]
[:title title]
[:link {:rel "stylesheet" :href "/static/publish.css"}]]
[:body
[:main.wrap
[:div.page-toolbar
(when graph-uuid
[:a.toolbar-btn {:href (str "/graph/" graph-uuid)} "Home"])]
[:div.password-card
[:h1 title]
[:p.tag-sub "This page is password protected."]
(when wrong?
[:p.password-error "Incorrect password."])
[:form.password-form {:method "GET"}
(when page-uuid
[:input {:type "hidden" :name "page" :value page-uuid}])
[:label.password-label {:for "publish-password"} "Enter password"]
[:input.password-input {:id "publish-password"
:name "password"
:type "password"
:placeholder "Password"
:required true}]
[:button.toolbar-btn {:type "submit"} "Unlock"]]]]]]]
(str "<!doctype html>" (render-hiccup doc))))
(defn render-404-html
[]
(let [title "Page not found"

View File

@@ -11,6 +11,35 @@
(def publish-css (resource/inline "logseq/publish/publish.css"))
(def publish-js (resource/inline "logseq/publish/publish.js"))
(defn- request-password
[request]
(let [url (js/URL. (.-url request))
query (.get (.-searchParams url) "password")
header (.get (.-headers request) "x-publish-password")]
(or header query)))
(defn- fetch-page-password-hash
[graph-uuid page-uuid env]
(js-await [^js do-ns (aget env "PUBLISH_META_DO")
do-id (.idFromName do-ns "index")
do-stub (.get do-ns do-id)
resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/password")
#js {:method "GET"})]
(when (.-ok resp)
(js-await [data (.json resp)]
(aget data "password_hash")))))
(defn- check-page-password
[request graph-uuid page-uuid env]
(js-await [stored-hash (fetch-page-password-hash graph-uuid page-uuid env)]
(if (string/blank? stored-hash)
{:allowed? true :provided? false}
(let [provided (request-password request)]
(if (string? provided)
(js-await [hashed (publish-common/sha256-hex provided)]
{:allowed? (= hashed stored-hash) :provided? true})
{:allowed? false :provided? false})))))
(defn handle-post-pages [request env]
(js-await [auth-header (.get (.-headers request) "authorization")
token (when (and auth-header (string/starts-with? auth-header "Bearer "))
@@ -37,6 +66,8 @@
(get payload "page-title")
(when page-eid
(publish-model/entity->title (get payload-entities page-eid))))
page-password-hash (or (:page-password-hash payload)
(get payload "page-password-hash"))
refs (when (and page-eid page-title)
(publish-index/page-refs-from-payload payload page-eid page_uuid page-title graph))
tagged-nodes (when (and page-eid page-title)
@@ -67,6 +98,7 @@
:page_title page-title
:page_tags (when page-tags
(js/JSON.stringify (clj->js page-tags)))
:password_hash page-password-hash
:graph graph-uuid
:schema_version schema_version
:block_count block_count
@@ -136,15 +168,18 @@
meta-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" page-uuid))]
(if-not (.-ok meta-resp)
(handle-tag-page-html graph-uuid page-uuid env)
(js-await [meta (.json meta-resp)
etag (aget meta "content_hash")
if-none-match (publish-common/normalize-etag (.get (.-headers request) "if-none-match"))]
(if (and etag if-none-match (= etag if-none-match))
(js/Response. nil #js {:status 304
:headers (publish-common/merge-headers
#js {:etag etag}
(publish-common/cors-headers))})
(publish-common/json-response (js->clj meta :keywordize-keys true) 200))))))))
(js-await [{:keys [allowed?]} (check-page-password request graph-uuid page-uuid env)]
(if-not allowed?
(publish-common/json-response {:error "password required"} 401)
(js-await [meta (.json meta-resp)
etag (aget meta "content_hash")
if-none-match (publish-common/normalize-etag (.get (.-headers request) "if-none-match"))]
(if (and etag if-none-match (= etag if-none-match))
(js/Response. nil #js {:status 304
:headers (publish-common/merge-headers
#js {:etag etag}
(publish-common/cors-headers))})
(publish-common/json-response (js->clj meta :keywordize-keys true) 200))))))))))
(defn handle-get-page-transit [request env]
(let [url (js/URL. (.-url request))
@@ -163,23 +198,26 @@
#js {:headers (publish-common/merge-headers
#js {"content-type" "text/html; charset=utf-8"}
(publish-common/cors-headers))})
(js-await [meta (.json meta-resp)
r2-key (aget meta "r2_key")]
(if-not r2-key
(publish-common/json-response {:error "missing transit"} 404)
(js-await [etag (aget meta "content_hash")
if-none-match (publish-common/normalize-etag (.get (.-headers request) "if-none-match"))
signed-url (when-not (and etag if-none-match (= etag if-none-match))
(publish-common/presign-r2-url r2-key env))]
(if (and etag if-none-match (= etag if-none-match))
(js/Response. nil #js {:status 304
:headers (publish-common/merge-headers
#js {:etag etag}
(publish-common/cors-headers))})
(publish-common/json-response {:url signed-url
:expires_in 300
:etag etag}
200))))))))))
(js-await [{:keys [allowed?]} (check-page-password request graph-uuid page-uuid env)]
(if-not allowed?
(publish-common/json-response {:error "password required"} 401)
(js-await [meta (.json meta-resp)
r2-key (aget meta "r2_key")]
(if-not r2-key
(publish-common/json-response {:error "missing transit"} 404)
(js-await [etag (aget meta "content_hash")
if-none-match (publish-common/normalize-etag (.get (.-headers request) "if-none-match"))
signed-url (when-not (and etag if-none-match (= etag if-none-match))
(publish-common/presign-r2-url r2-key env))]
(if (and etag if-none-match (= etag if-none-match))
(js/Response. nil #js {:status 304
:headers (publish-common/merge-headers
#js {:etag etag}
(publish-common/cors-headers))})
(publish-common/json-response {:url signed-url
:expires_in 300
:etag etag}
200))))))))))))
(defn handle-get-page-refs [request env]
(let [url (js/URL. (.-url request))
@@ -188,18 +226,21 @@
page-uuid (nth parts 3 nil)]
(if (or (nil? graph-uuid) (nil? page-uuid))
(publish-common/bad-request "missing graph uuid or page uuid")
(js-await [^js do-ns (aget env "PUBLISH_META_DO")
do-id (.idFromName do-ns "index")
do-stub (.get do-ns do-id)
refs-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/refs"))]
(if-not (.-ok refs-resp)
(js/Response.
(publish-render/render-404-html)
#js {:headers (publish-common/merge-headers
#js {"content-type" "text/html; charset=utf-8"}
(publish-common/cors-headers))})
(js-await [refs (.json refs-resp)]
(publish-common/json-response (js->clj refs :keywordize-keys true) 200)))))))
(js-await [{:keys [allowed?]} (check-page-password request graph-uuid page-uuid env)]
(if-not allowed?
(publish-common/json-response {:error "password required"} 401)
(js-await [^js do-ns (aget env "PUBLISH_META_DO")
do-id (.idFromName do-ns "index")
do-stub (.get do-ns do-id)
refs-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/refs"))]
(if-not (.-ok refs-resp)
(js/Response.
(publish-render/render-404-html)
#js {:headers (publish-common/merge-headers
#js {"content-type" "text/html; charset=utf-8"}
(publish-common/cors-headers))})
(js-await [refs (.json refs-resp)]
(publish-common/json-response (js->clj refs :keywordize-keys true) 200)))))))))
(defn handle-get-page-tagged-nodes [request env]
(let [url (js/URL. (.-url request))
@@ -208,14 +249,17 @@
page-uuid (nth parts 3 nil)]
(if (or (nil? graph-uuid) (nil? page-uuid))
(publish-common/bad-request "missing graph uuid or page uuid")
(js-await [^js do-ns (aget env "PUBLISH_META_DO")
do-id (.idFromName do-ns "index")
do-stub (.get do-ns do-id)
tags-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/tagged_nodes"))]
(if-not (.-ok tags-resp)
(publish-common/not-found)
(js-await [tags (.json tags-resp)]
(publish-common/json-response (js->clj tags :keywordize-keys true) 200)))))))
(js-await [{:keys [allowed?]} (check-page-password request graph-uuid page-uuid env)]
(if-not allowed?
(publish-common/json-response {:error "password required"} 401)
(js-await [^js do-ns (aget env "PUBLISH_META_DO")
do-id (.idFromName do-ns "index")
do-stub (.get do-ns do-id)
tags-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/tagged_nodes"))]
(if-not (.-ok tags-resp)
(publish-common/not-found)
(js-await [tags (.json tags-resp)]
(publish-common/json-response (js->clj tags :keywordize-keys true) 200)))))))))
(defn handle-list-pages [env]
(js-await [^js do-ns (aget env "PUBLISH_META_DO")
@@ -439,30 +483,38 @@
#js {:headers (publish-common/merge-headers
#js {"content-type" "text/html; charset=utf-8"}
(publish-common/cors-headers))}))
(js-await [meta (.json meta-resp)
index-id (.idFromName do-ns "index")
index-stub (.get do-ns index-id)
refs-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/refs"))
refs-json (when (and refs-resp (.-ok refs-resp))
(js-await [raw (.json refs-resp)]
(js->clj raw :keywordize-keys false)))
tags-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/tagged_nodes")
#js {:method "GET"})
tagged-nodes (when (and tags-resp (.-ok tags-resp))
(js-await [raw (.json tags-resp)]
(js->clj (or (aget raw "tagged_nodes") #js [])
:keywordize-keys true)))
r2 (aget env "PUBLISH_R2")
object (.get r2 (aget meta "r2_key"))]
(if-not object
(publish-common/json-response {:error "missing transit blob"} 404)
(js-await [buffer (.arrayBuffer object)
transit (.decode publish-common/text-decoder buffer)]
(js/Response.
(publish-render/render-page-html transit page-uuid refs-json tagged-nodes)
#js {:headers (publish-common/merge-headers
#js {"content-type" "text/html; charset=utf-8"}
(publish-common/cors-headers))})))))))))
(js-await [{:keys [allowed? provided?]} (check-page-password request graph-uuid page-uuid env)]
(if-not allowed?
(js/Response.
(publish-render/render-password-html graph-uuid page-uuid provided?)
#js {:status 401
:headers (publish-common/merge-headers
#js {"content-type" "text/html; charset=utf-8"}
(publish-common/cors-headers))})
(js-await [meta (.json meta-resp)
index-id (.idFromName do-ns "index")
index-stub (.get do-ns index-id)
refs-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/refs"))
refs-json (when (and refs-resp (.-ok refs-resp))
(js-await [raw (.json refs-resp)]
(js->clj raw :keywordize-keys false)))
tags-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/tagged_nodes")
#js {:method "GET"})
tagged-nodes (when (and tags-resp (.-ok tags-resp))
(js-await [raw (.json tags-resp)]
(js->clj (or (aget raw "tagged_nodes") #js [])
:keywordize-keys true)))
r2 (aget env "PUBLISH_R2")
object (.get r2 (aget meta "r2_key"))]
(if-not object
(publish-common/json-response {:error "missing transit blob"} 404)
(js-await [buffer (.arrayBuffer object)
transit (.decode publish-common/text-decoder buffer)]
(js/Response.
(publish-render/render-page-html transit page-uuid refs-json tagged-nodes)
#js {:headers (publish-common/merge-headers
#js {"content-type" "text/html; charset=utf-8"}
(publish-common/cors-headers))})))))))))))
(defn handle-fetch [request env]
(let [url (js/URL. (.-url request))

View File

@@ -16,8 +16,42 @@
[frontend.util.page :as page-util]
[logseq.common.path :as path]
[logseq.db :as ldb]
[logseq.shui.hooks :as hooks]
[logseq.shui.ui :as shui]
[promesa.core :as p]))
[promesa.core :as p]
[rum.core :as rum]))
(rum/defc publish-page-dialog
[page]
(let [[password set-password!] (hooks/use-state "")
[publishing? set-publishing!] (hooks/use-state false)
submit! (fn []
(when-not publishing?
(set-publishing! true)
(-> (publish-handler/publish-page! page {:password password})
(p/finally (fn []
(set-publishing! false)
(shui/dialog-close!))))))]
[:div.flex.flex-col.gap-4.p-2
[:div.text-lg.font-medium "Publish page"]
[:div.text-sm.opacity-70
"Optionally protect this page with a password. Leave empty for public access."]
(shui/toggle-password
{:placeholder "Optional password"
:value password
:on-change (fn [e]
(set-password! (util/evalue e)))})
[:div.flex.justify-end.gap-2
(shui/button
{:variant "ghost"
:on-click #(shui/dialog-close!)}
"Cancel")
(shui/button
{:on-click submit!
:disabled publishing?}
(if publishing?
"Publishing..."
"Publish"))]]))
(defn- delete-page!
[page]
@@ -100,7 +134,8 @@
(when (and page (not config/publishing?))
{:title "Publish page"
:options {:on-click #(publish-handler/publish-page! page)}})
:options {:on-click #(shui/dialog-open! (fn [] (publish-page-dialog page))
{:class "w-auto max-w-md"})}})
(when (util/electron?)
{:title (t (if public? :page/make-private :page/make-public))

View File

@@ -119,11 +119,18 @@
assets)))))
(defn- <post-publish!
[payload]
[payload {:keys [password]}]
(let [token (state/get-auth-id-token)
headers (cond-> {"content-type" "application/transit+json"}
token (assoc "authorization" (str "Bearer " token)))]
(p/let [body (ldb/write-transit-str payload)
(p/let [page-password (some-> password string/trim)
page-password (when (and (string? page-password)
(not (string/blank? page-password)))
page-password)
page-password-hash (when page-password (<sha256-hex page-password))
payload (cond-> payload
page-password-hash (assoc :page-password-hash page-password-hash))
body (ldb/write-transit-str payload)
content-hash (<sha256-hex body)
graph-uuid (or (:graph-uuid payload)
(some-> (ldb/get-graph-rtc-uuid (db/get-db)) str))
@@ -153,7 +160,7 @@
(defn publish-page!
"Prepares and uploads the publish payload for a page."
[page]
[page & [{:keys [password]}]]
(let [repo (state/get-current-repo)]
(when-let [db* (and repo (db/get-db repo))]
(if (and page (:db/id page))
@@ -164,7 +171,7 @@
graph-uuid)]
(if payload
(-> (p/let [_ (<upload-assets! repo graph-uuid payload)]
(<post-publish! payload))
(<post-publish! payload {:password password}))
(p/then (fn [resp]
(p/let [json (.json resp)
data (bean/->clj json)]