├── resources
├── static
├── migrations
│ ├── 20200714115835_pages_table_drop_tags.edn
│ ├── 20200706160943_pages_table_add_tags_column.edn
│ ├── 20200716054759_add_website_to_users_table.edn
│ ├── 20200420045653_make_email_optional.edn
│ ├── 20200829084541_repos_add_installation_id.edn
│ ├── 20200706155251_pages_table_add_settings_column.edn
│ ├── 20200716055743_add_public_to_repos.edn
│ ├── 20200805053529_Add_cors_proxy_to_users_table.edn
│ ├── 20200512023556_users_table_add_prefered_format.edn
│ ├── 20200513091631_users_table_add_repo_granted.edn
│ ├── 20201001041015_repos_table_add_branch.edn
│ ├── 20200515013538_users_table_add_encrypt_object.edn
│ ├── 20200514102219_users_table_drop_column_repo_granted.edn
│ ├── 20200707091621_users_drop_column_github_id.edn
│ ├── 20200812044935_Add_preferred_workflow_to_users_table.edn
│ ├── 20200829114108_drop_column_encrypt_object_key.edn
│ ├── 20200630153940_users_add_name_unique_index.edn
│ ├── 20200829084420_users_table_drop_github_installation_id.edn
│ ├── 20200717002759_projects_add_settings_column.edn
│ ├── 20200427024428_repos_add_user_id_ref.edn
│ ├── 20200828110530_add_installation_id_to_users.edn
│ ├── 20200221045345_create_table_refresh_tokens.edn
│ ├── 20210705152715_oauth_users_table_remove_identity_column.edn
│ ├── 20200715120654_pages_table_add_project_id.edn
│ ├── 20201129130450_Delete_projects_when_user_deleted.edn
│ ├── 20200221044628_create_table_repos.edn
│ ├── 20200714132453_create_projects_table.edn
│ ├── 20200221043329_create_table_users.edn
│ ├── 20200630145939_create_table_pages.edn
│ └── 20201223153550_create_table_oauth_users.edn
├── logback.xml
├── config.edn
└── js
│ ├── magic_portal.js
│ └── worker.js
├── CHECKS
├── Procfile
├── .gitmodules
├── test
└── app
│ ├── core_test.clj
│ └── result_test.clj
├── src
├── main
│ └── app
│ │ ├── handler
│ │ ├── user.clj
│ │ ├── utils.clj
│ │ ├── rss.clj
│ │ ├── project.clj
│ │ ├── auth.clj
│ │ └── page.clj
│ │ ├── db_migrate.clj
│ │ ├── reserved_routes.clj
│ │ ├── config.clj
│ │ ├── components
│ │ ├── services.clj
│ │ ├── http.clj
│ │ └── hikari.clj
│ │ ├── jwt.clj
│ │ ├── migration_scripts
│ │ └── dump_users_into_oauth_users.clj
│ │ ├── spec.clj
│ │ ├── core.clj
│ │ ├── db
│ │ ├── refresh_token.clj
│ │ ├── user.clj
│ │ ├── util.clj
│ │ ├── oauth_user.clj
│ │ ├── repo.clj
│ │ ├── page.clj
│ │ └── project.clj
│ │ ├── s3.clj
│ │ ├── result.clj
│ │ ├── aws.clj
│ │ ├── slack.clj
│ │ ├── interceptor
│ │ ├── etag.clj
│ │ └── gzip.clj
│ │ ├── cookie.clj
│ │ ├── http.clj
│ │ ├── interceptors.clj
│ │ ├── util.clj
│ │ ├── system.clj
│ │ ├── github.clj
│ │ ├── routes.clj
│ │ └── views
│ │ └── home.clj
└── dev
│ └── user.clj
├── .gitignore
├── project.clj
├── deps.edn
└── readme.org
/resources/static:
--------------------------------------------------------------------------------
1 | ../web/static
--------------------------------------------------------------------------------
/CHECKS:
--------------------------------------------------------------------------------
1 | WAIT=10
2 | ATTEMPTS=6
3 | /check.txt dokku-check
4 |
--------------------------------------------------------------------------------
/Procfile:
--------------------------------------------------------------------------------
1 | web: java -Dclojure.main.report=stderr -cp target/logseq.jar clojure.main -m app.core
2 |
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "web"]
2 | path = web
3 | url = https://github.com/logseq/logseq
4 |
--------------------------------------------------------------------------------
/resources/migrations/20200714115835_pages_table_drop_tags.edn:
--------------------------------------------------------------------------------
1 | {:up ["alter table pages drop column tags"]
2 | :down ["ALTER TABLE pages ADD COLUMN tags text[];"]}
3 |
--------------------------------------------------------------------------------
/resources/migrations/20200706160943_pages_table_add_tags_column.edn:
--------------------------------------------------------------------------------
1 | {:up ["ALTER TABLE pages ADD COLUMN tags text[];"]
2 | :down ["alter table pages drop column tags"]}
3 |
--------------------------------------------------------------------------------
/resources/migrations/20200716054759_add_website_to_users_table.edn:
--------------------------------------------------------------------------------
1 | {:up ["ALTER TABLE users ADD COLUMN website text;"]
2 | :down ["alter table users drop column website"]}
3 |
--------------------------------------------------------------------------------
/resources/migrations/20200420045653_make_email_optional.edn:
--------------------------------------------------------------------------------
1 | {:up ["alter table users alter email drop not null"]
2 | :down ["alter table users alter column email set not null"]}
3 |
--------------------------------------------------------------------------------
/resources/migrations/20200829084541_repos_add_installation_id.edn:
--------------------------------------------------------------------------------
1 | {:up ["alter table repos add column installation_id text"]
2 | :down ["alter table repos drop installation_id"]}
3 |
--------------------------------------------------------------------------------
/resources/migrations/20200706155251_pages_table_add_settings_column.edn:
--------------------------------------------------------------------------------
1 | {:up ["ALTER TABLE pages ADD COLUMN settings jsonb;"]
2 | :down ["alter table pages drop column settings"]}
3 |
--------------------------------------------------------------------------------
/resources/migrations/20200716055743_add_public_to_repos.edn:
--------------------------------------------------------------------------------
1 | {:up ["ALTER TABLE repos ADD COLUMN public boolean default false;"]
2 | :down ["alter table repos drop column public"]}
3 |
--------------------------------------------------------------------------------
/resources/migrations/20200805053529_Add_cors_proxy_to_users_table.edn:
--------------------------------------------------------------------------------
1 | {:up ["alter table users add cors_proxy text default null"]
2 | :down ["alter table users drop column cors_proxy"]}
3 |
--------------------------------------------------------------------------------
/resources/migrations/20200512023556_users_table_add_prefered_format.edn:
--------------------------------------------------------------------------------
1 | {:up ["alter table users add preferred_format text default null"]
2 | :down ["alter table users drop preferred_format"]}
3 |
--------------------------------------------------------------------------------
/resources/migrations/20200513091631_users_table_add_repo_granted.edn:
--------------------------------------------------------------------------------
1 | {:up ["alter table users add repo_granted boolean default false"]
2 | :down ["alter table users drop column repo_granted"]}
3 |
--------------------------------------------------------------------------------
/resources/migrations/20201001041015_repos_table_add_branch.edn:
--------------------------------------------------------------------------------
1 | {:up ["ALTER TABLE repos ADD COLUMN branch text default 'master' not null;"]
2 | :down ["alter table repos drop column branch"]}
3 |
--------------------------------------------------------------------------------
/resources/migrations/20200515013538_users_table_add_encrypt_object.edn:
--------------------------------------------------------------------------------
1 | {:up ["alter table users add encrypt_object_key text default null"]
2 | :down ["alter table users drop encrypt_object_key"]}
3 |
--------------------------------------------------------------------------------
/resources/migrations/20200514102219_users_table_drop_column_repo_granted.edn:
--------------------------------------------------------------------------------
1 | {:up ["alter table users drop column repo_granted"]
2 | :down ["alter table users add repo_granted boolean default false"]}
3 |
--------------------------------------------------------------------------------
/resources/migrations/20200707091621_users_drop_column_github_id.edn:
--------------------------------------------------------------------------------
1 | {:up ["alter table users drop column github_id"]
2 | :down ["alter table users add column github_id text not null unique default ''"]}
3 |
--------------------------------------------------------------------------------
/resources/migrations/20200812044935_Add_preferred_workflow_to_users_table.edn:
--------------------------------------------------------------------------------
1 | {:up ["alter table users add preferred_workflow text default 'now'"]
2 | :down ["alter table users drop preferred_workflow"]}
3 |
--------------------------------------------------------------------------------
/resources/migrations/20200829114108_drop_column_encrypt_object_key.edn:
--------------------------------------------------------------------------------
1 | {:up ["alter table users drop column encrypt_object_key"]
2 | :down ["alter table users add encrypt_object_key text default null"]}
3 |
--------------------------------------------------------------------------------
/test/app/core_test.clj:
--------------------------------------------------------------------------------
1 | (ns app.core-test
2 | (:require [clojure.test :refer :all]
3 | [app.core :refer :all]))
4 |
5 | (deftest a-test
6 | (testing "FIXME, I fail."
7 | (is (= 0 1))))
8 |
--------------------------------------------------------------------------------
/resources/migrations/20200630153940_users_add_name_unique_index.edn:
--------------------------------------------------------------------------------
1 | {:up ["ALTER TABLE users ADD CONSTRAINT users_name_key UNIQUE (name);"]
2 | :down ["ALTER TABLE users DROP CONSTRAINT users_name_key;"]}
3 |
--------------------------------------------------------------------------------
/resources/migrations/20200829084420_users_table_drop_github_installation_id.edn:
--------------------------------------------------------------------------------
1 | {:up ["alter table users drop github_installation_id"]
2 | :down ["alter table users add column github_installation_id text UNIQUE"]}
3 |
--------------------------------------------------------------------------------
/resources/migrations/20200717002759_projects_add_settings_column.edn:
--------------------------------------------------------------------------------
1 | {:up ["ALTER TABLE projects ADD COLUMN settings jsonb;"
2 | "ALTER TABLE projects drop COLUMN description;"]
3 | :down ["alter table projects drop column settings"
4 | "alter table projects add column description text"]}
5 |
--------------------------------------------------------------------------------
/resources/migrations/20200427024428_repos_add_user_id_ref.edn:
--------------------------------------------------------------------------------
1 | {:up ["ALTER TABLE repos
2 | ADD CONSTRAINT repos_users_fkey FOREIGN KEY (user_id)
3 | REFERENCES users (id)
4 | ON UPDATE CASCADE ON DELETE CASCADE;"]
5 | :down ["ALTER TABLE repos
6 | DROP CONSTRAINT repos_users_fkey;
7 | "]}
8 |
--------------------------------------------------------------------------------
/resources/migrations/20200828110530_add_installation_id_to_users.edn:
--------------------------------------------------------------------------------
1 | ;; We have to keep installaction_id for now, read more at here:
2 | ;; https://twitter.com/logseq/status/1299215315050491906
3 | {:up ["alter table users add column github_installation_id text UNIQUE"]
4 | :down ["alter table users drop column github_installation_id"]}
5 |
--------------------------------------------------------------------------------
/src/main/app/handler/user.clj:
--------------------------------------------------------------------------------
1 | (ns app.handler.user
2 | (:require [app.db
3 | [user :as u]]
4 | [app.result :as r]
5 | [app.http :as h]
6 | [app.handler.utils :as hu]))
7 |
8 | (defn delete!
9 | [{:keys [app-context path-params] :as req}]
10 | (r/let-r [user (hu/login? app-context)]
11 | (u/delete (:id user))
12 | (h/success true)))
13 |
--------------------------------------------------------------------------------
/resources/migrations/20200221045345_create_table_refresh_tokens.edn:
--------------------------------------------------------------------------------
1 | {:up ["CREATE TABLE refresh_tokens (
2 | user_id uuid NOT NULL UNIQUE,
3 | token uuid NOT NULL UNIQUE
4 | )"
5 | "ALTER TABLE refresh_tokens
6 | ADD CONSTRAINT refresh_tokens_users_fkey FOREIGN KEY (user_id)
7 | REFERENCES users (id)
8 | ON UPDATE CASCADE ON DELETE CASCADE;"]
9 | :down ["drop table refresh_tokens"]}
10 |
--------------------------------------------------------------------------------
/src/main/app/db_migrate.clj:
--------------------------------------------------------------------------------
1 | (ns app.db-migrate
2 | (:require [ragtime.jdbc :as jdbc]
3 | [ragtime.repl :as repl]))
4 |
5 | ;; db migrations
6 | (defn load-config
7 | [db]
8 | {:datastore (jdbc/sql-database db)
9 | :migrations (jdbc/load-resources "migrations")})
10 |
11 | (defn migrate [db]
12 | (repl/migrate (load-config db)))
13 |
14 | (defn rollback [db]
15 | (repl/rollback (load-config db)))
16 |
--------------------------------------------------------------------------------
/src/main/app/reserved_routes.clj:
--------------------------------------------------------------------------------
1 | (ns app.reserved-routes)
2 |
3 | (defonce reserved
4 | #{"changelog" "help" "docs" "apps" "downloads" "membership"
5 | "about" "careers" "new" "logseq" "privacy" "terms"
6 | "upload" "add"
7 |
8 | ;; frontend routes
9 | "repos" "all-files" "file" "new-page" "page" "all-pages"
10 | "graph" "diff" "draw"})
11 |
12 | (defn reserved?
13 | [s]
14 | (contains? reserved s))
15 |
--------------------------------------------------------------------------------
/resources/migrations/20210705152715_oauth_users_table_remove_identity_column.edn:
--------------------------------------------------------------------------------
1 | {:up ["ALTER TABLE oauth_users DROP CONSTRAINT oauth_users_oauth_source_identity_key;"
2 | "ALTER TABLE oauth_users drop column identity;"
3 | "Delete from oauth_users where open_id is null;"
4 | "alter table oauth_users alter column open_id set not null;"
5 | "CREATE UNIQUE INDEX oauth_users_oauth_source_open_id_key ON oauth_users(oauth_source, open_id);"]
6 | :down [""]}
7 |
--------------------------------------------------------------------------------
/resources/migrations/20200715120654_pages_table_add_project_id.edn:
--------------------------------------------------------------------------------
1 | {:up ["alter table pages drop column project_id"
2 | "ALTER TABLE pages ADD COLUMN project_id uuid not null;"
3 | "ALTER TABLE pages
4 | ADD CONSTRAINT projects_pages_fkey FOREIGN KEY (project_id)
5 | REFERENCES projects (id)
6 | ON UPDATE CASCADE ON DELETE CASCADE;"]
7 | :down ["ALTER TABLE pages DROP CONSTRAINT projects_pages_fkey; "
8 | "ALTER TABLE pages DROP COLUMN project_id;"]}
9 |
--------------------------------------------------------------------------------
/resources/migrations/20201129130450_Delete_projects_when_user_deleted.edn:
--------------------------------------------------------------------------------
1 | {:up ["DELETE FROM projects
2 | WHERE NOT EXISTS(SELECT NULL
3 | FROM users u
4 | WHERE u.id = user_id)"
5 | "ALTER TABLE projects
6 | ADD CONSTRAINT projects_users_fkey FOREIGN KEY (user_id)
7 | REFERENCES users (id)
8 | ON UPDATE CASCADE ON DELETE CASCADE;"]
9 | :down ["ALTER TABLE projects
10 | DROP CONSTRAINT projects_users_fkey;
11 | "]}
12 |
--------------------------------------------------------------------------------
/src/main/app/handler/utils.clj:
--------------------------------------------------------------------------------
1 | (ns app.handler.utils
2 | (:require [app.result :as r]
3 | [app.http :as h]
4 | [app.db.project :as project]))
5 |
6 | (defn login?
7 | [app-context]
8 | (let [user (:user app-context)]
9 | (if (map? user)
10 | (r/success user)
11 | (h/unauthorized))))
12 |
13 | (defn permit-to-access-project?
14 | [user project-name]
15 | (if (project/belongs-to? project-name (:id user))
16 | (r/success)
17 | (h/forbidden)))
--------------------------------------------------------------------------------
/resources/migrations/20200221044628_create_table_repos.edn:
--------------------------------------------------------------------------------
1 | {:up ["CREATE TABLE repos (
2 | id uuid DEFAULT uuid_generate_v4() NOT NULL UNIQUE,
3 | user_id uuid NOT NULL,
4 | url text NOT NULL,
5 | created_at timestamp with time zone DEFAULT timezone('UTC'::text, now()) NOT NULL,
6 | CONSTRAINT created_at_chk CHECK ((date_part('timezone'::text, created_at) = '0'::double precision))
7 | );"
8 | "CREATE UNIQUE INDEX idx_repos_user_repo ON repos(user_id, url);"]
9 | :down ["drop table repos"]}
10 |
--------------------------------------------------------------------------------
/resources/migrations/20200714132453_create_projects_table.edn:
--------------------------------------------------------------------------------
1 | {:up ["CREATE TABLE projects (
2 | id uuid DEFAULT uuid_generate_v4() NOT NULL UNIQUE,
3 | user_id uuid NOT NULL,
4 | repo_id uuid,
5 | name text NOT NULL unique,
6 | description text,
7 | created_at timestamp with time zone DEFAULT timezone('UTC'::text, now()) NOT NULL,
8 | CONSTRAINT created_at_chk CHECK ((date_part('timezone'::text, created_at) = '0'::double precision))
9 | );"
10 | "ALTER table pages add column project_id uuid"]
11 | :down ["drop table projects"]}
12 |
--------------------------------------------------------------------------------
/src/main/app/config.clj:
--------------------------------------------------------------------------------
1 | (ns app.config
2 | (:require [aero.core :refer (read-config)]
3 | [clojure.java.io :as io]))
4 |
5 | (def config (read-config (io/resource "config.edn")))
6 |
7 | (def production? (= "production" (:env config)))
8 | (def dev? (= "dev" (:env config)))
9 |
10 | (def test? (= "test" (:env config)))
11 | (def staging? (= "staging" (:env config)))
12 | (def website-uri (:website-uri config))
13 | (def cookie-domain (:cookie-domain config))
14 |
15 | (def cdn-uri (:cdn-uri config))
16 | (def asset-uri (:asset-uri config))
17 |
--------------------------------------------------------------------------------
/resources/migrations/20200221043329_create_table_users.edn:
--------------------------------------------------------------------------------
1 | {:up ["create extension if not exists \"uuid-ossp\";
2 | CREATE TABLE users (
3 | id uuid DEFAULT uuid_generate_v4() NOT NULL UNIQUE,
4 | name text NOT NULL,
5 | email text NOT NULL UNIQUE,
6 | avatar text NOT NULL,
7 | github_id text not null unique,
8 | created_at timestamp with time zone DEFAULT timezone('UTC'::text, now()) NOT NULL,
9 | CONSTRAINT created_at_chk CHECK ((date_part('timezone'::text, created_at) = '0'::double precision))
10 | );
11 | "]
12 | :down ["drop table users"]}
13 |
--------------------------------------------------------------------------------
/src/main/app/components/services.clj:
--------------------------------------------------------------------------------
1 | (ns app.components.services
2 | (:require [com.stuartsierra.component :as component]
3 | [clojure.tools.nrepl
4 | [server :as repl]
5 | [transport :as nrepl-t]]))
6 |
7 | (defn- start-nrepl
8 | []
9 | (repl/start-server :port 31415 :transport-fn nrepl-t/tty))
10 |
11 | (defrecord Services [service-map repl]
12 | component/Lifecycle
13 | (start [this]
14 | (let [repl (start-nrepl)]
15 | (assoc this :repl repl)))
16 | (stop [this]
17 | (repl/stop-server repl)))
18 |
19 | (defn new-services []
20 | (map->Services {}))
21 |
--------------------------------------------------------------------------------
/src/main/app/jwt.clj:
--------------------------------------------------------------------------------
1 | (ns app.jwt
2 | (:require [buddy.sign.jwt :as jwt]
3 | [clj-time.core :as time]
4 | [app.config :refer [config]]))
5 |
6 | (defonce secret (:jwt-secret config))
7 |
8 | (defn sign
9 | "Serialize and sign a token with defined claims"
10 | ([m]
11 | (sign m (* 60 60 24 30)))
12 | ([m expire-secs]
13 | (let [claims (assoc m
14 | :exp (time/plus (time/now) (time/seconds expire-secs)))]
15 | (jwt/sign claims secret))))
16 |
17 | (defn unsign
18 | [token]
19 | (jwt/unsign token secret))
20 |
21 | (defn unsign-skip-validation
22 | [token]
23 | (jwt/unsign token secret {:skip-validation true}))
24 |
--------------------------------------------------------------------------------
/resources/migrations/20200630145939_create_table_pages.edn:
--------------------------------------------------------------------------------
1 | {:up ["CREATE TABLE pages (
2 | id uuid DEFAULT uuid_generate_v4() NOT NULL UNIQUE,
3 | user_id uuid NOT NULL,
4 | permalink text NOT NULL,
5 | title text NOT NULL,
6 | html text NOT NULL,
7 | published_at timestamp with time zone DEFAULT timezone('UTC'::text, now()) NOT NULL,
8 | updated_at timestamp with time zone DEFAULT timezone('UTC'::text, now()) NOT NULL,
9 | CONSTRAINT published_at_chk CHECK ((date_part('timezone'::text, published_at) = '0'::double precision)),
10 | CONSTRAINT updated_at_chk CHECK ((date_part('timezone'::text, updated_at) = '0'::double precision))
11 | );"
12 | "CREATE UNIQUE INDEX idx_pages_user_page ON pages(user_id, permalink);"]
13 | :down ["drop table pages"]}
14 |
--------------------------------------------------------------------------------
/src/main/app/components/http.clj:
--------------------------------------------------------------------------------
1 | (ns app.components.http
2 | (:require [com.stuartsierra.component :as component]
3 | [io.pedestal.http :as http]))
4 |
5 | (defn test?
6 | [service-map]
7 | (= :test (:env service-map)))
8 |
9 | (defrecord Server [service-map service]
10 | component/Lifecycle
11 | (start [this]
12 | (if service
13 | this
14 | (cond-> service-map
15 | true http/create-server
16 | (not (test? service-map)) http/start
17 | true ((partial assoc this :service)))))
18 | (stop [this]
19 | (when (and service (not (test? service-map)))
20 | (http/stop service))
21 | (assoc this :service nil)))
22 |
23 | (defn new-server
24 | []
25 | (map->Server {}))
26 |
--------------------------------------------------------------------------------
/src/main/app/migration_scripts/dump_users_into_oauth_users.clj:
--------------------------------------------------------------------------------
1 | (ns app.migration-scripts.dump-users-into-oauth-users
2 | (:require [toucan.db :as db])
3 | (:require [app.db
4 | [user :as user]
5 | [oauth-user :as oauth-user]]
6 | [clojure.set :as set]))
7 |
8 | (defn get-all-users
9 | []
10 | (db/select user/User))
11 |
12 | (defn -main
13 | []
14 | (let [users (get-all-users)]
15 | (doseq [user users]
16 | (let [m (-> (select-keys user [:email :name :id :avatar])
17 | (set/rename-keys {:id :user_id})
18 | (assoc :oauth_source :github))]
19 | (prn "insert into oauth-user:" m)
20 | (try
21 | (oauth-user/insert m)
22 | (catch Throwable t
23 | (prn t)))))))
24 |
--------------------------------------------------------------------------------
/src/main/app/spec.clj:
--------------------------------------------------------------------------------
1 | (ns app.spec
2 | (:require [clojure.spec.alpha :as s]
3 | [expound.alpha :as expound]
4 | [io.pedestal.log :as log]
5 | [app.config :as config]))
6 |
7 | (when config/dev? (s/check-asserts true))
8 |
9 | ;;(set! s/*explain-out* expound/printer)
10 |
11 | (defn validate
12 | "This function won't crash the current thread, just log error."
13 | [spec value]
14 | (when config/dev?
15 | (if (s/explain-data spec value)
16 | (let [error-message (expound/expound-str spec value)
17 | ex (ex-info "Error in validate" {})]
18 | (log/error :exception ex :spec/validate-failed error-message)
19 | false)
20 | true)))
21 |
22 | (s/def :oauth/oauth_source keyword?)
23 | (s/def :oauth/name string?)
24 | (s/def :oauth/email string?)
25 | (s/def :oauth/avatar string?)
26 | (s/def :oauth/open_id string?)
27 |
--------------------------------------------------------------------------------
/resources/migrations/20201223153550_create_table_oauth_users.edn:
--------------------------------------------------------------------------------
1 | {:up ["CREATE TYPE oauth_source AS ENUM ('google', 'github');
2 | CREATE TABLE oauth_users (
3 | id uuid DEFAULT uuid_generate_v4() NOT NULL UNIQUE,
4 | user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE ON UPDATE CASCADE,
5 | oauth_source oauth_source NOT NULL,
6 | identity text NOT NULL,
7 | name text,
8 | email text,
9 | avatar text,
10 | open_id text,
11 | updated_at timestamp with time zone DEFAULT timezone('UTC'::text, now()) NOT NULL,
12 | created_at timestamp with time zone DEFAULT timezone('UTC'::text, now()) NOT NULL,
13 | UNIQUE (oauth_source, identity),
14 | CONSTRAINT updated_at_chk CHECK ((date_part('timezone'::text, updated_at) = '0'::double precision)),
15 | CONSTRAINT created_at_chk CHECK ((date_part('timezone'::text, created_at) = '0'::double precision))
16 | );
17 | "]
18 | :down ["DROP TABLE oauth_users;
19 | DROP TYPE oauth_source;"]}
20 |
--------------------------------------------------------------------------------
/src/main/app/components/hikari.clj:
--------------------------------------------------------------------------------
1 | (ns app.components.hikari
2 | (:require [com.stuartsierra.component :as component]
3 | [hikari-cp.core :as hikari]
4 | [clojure.java.jdbc :as j]
5 | [toucan.db :as toucan]
6 | [app.db-migrate :as migrate]))
7 |
8 | (defrecord Hikari [db-spec datasource]
9 | component/Lifecycle
10 | (start [component]
11 | (let [s (or datasource (hikari/make-datasource db-spec))]
12 | ;; migrate
13 | (migrate/migrate {:datasource s})
14 | ;; (try
15 | ;; (migrate/migrate {:datasource s})
16 | ;; (catch Exception e
17 | ;; (prn "DB migrate failed: " e)))
18 | (toucan/set-default-db-connection! {:datasource s})
19 | (assoc component :datasource s)))
20 | (stop [component]
21 | (when datasource
22 | (hikari/close-datasource datasource))
23 | (assoc component :datasource nil)))
24 |
25 | (defn new-hikari-cp [db-spec]
26 | (map->Hikari {:db-spec db-spec}))
27 |
--------------------------------------------------------------------------------
/src/main/app/core.clj:
--------------------------------------------------------------------------------
1 | (ns app.core
2 | (:require [app.config :as config]
3 | [app.system :as system]
4 | [app.util :as util]
5 | [taoensso.timbre :as timbre]
6 | [taoensso.timbre.appenders.core :as appenders]
7 | [com.stuartsierra.component :as component])
8 | (:gen-class))
9 |
10 | (defn set-logger!
11 | [log-path]
12 | (timbre/merge-config! (cond->
13 | {:level :info
14 | :appenders {:spit (appenders/spit-appender {:fname log-path})}}
15 | config/production?
16 | (assoc :output-fn (partial timbre/default-output-fn {:stacktrace-fonts {}})))))
17 |
18 | (defn start []
19 | (System/setProperty "https.protocols" "TLSv1.2,TLSv1.1,SSLv3")
20 | (set-logger! (:log-path config/config))
21 |
22 | (let [system (system/new-system (util/attach-db-spec! config/config))]
23 | (component/start system)))
24 |
25 | (defn -main [& args]
26 | (start))
27 |
--------------------------------------------------------------------------------
/src/main/app/db/refresh_token.clj:
--------------------------------------------------------------------------------
1 | (ns app.db.refresh-token
2 | (:refer-clojure :exclude [get update])
3 | (:require [toucan.db :as db]
4 | [toucan.models :as model]
5 | [app.util :as util]))
6 |
7 | (model/defmodel RefreshToken :refresh_tokens
8 | model/IModel
9 | (primary-key [_] :user_id))
10 |
11 | (defn get-token
12 | [user-id]
13 | (db/select-one-field :token RefreshToken :user_id user-id))
14 |
15 | (defn token-exists?
16 | [token]
17 | (db/exists? RefreshToken :token token))
18 |
19 | (defn get-user-id-by-token
20 | [token]
21 | (db/select-one-field :user_id RefreshToken :token token))
22 |
23 | (defn create
24 | [user-id]
25 | (when user-id
26 | (if-let [token (get-token user-id)]
27 | token
28 | (loop [token (util/uuid)]
29 | (if (token-exists? token)
30 | (recur (util/uuid))
31 | (do
32 | (db/insert! RefreshToken {:user_id user-id
33 | :token token})
34 | token))))))
35 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .env
2 | /target
3 | /classes
4 | /checkouts
5 | profiles.clj
6 | pom.xml
7 | pom.xml.asc
8 | *.jar
9 | *.class
10 | /.lein-*
11 | /.nrepl-port
12 | .hgignore
13 | .hg/
14 |
15 | node_modules/
16 | /resources/static/js/*.map
17 | /resources/static/js/main.js
18 | /resources/static/js/cljs-runtime
19 | /resources/static/js/manifest.edn
20 | /resources/static/js/publishing/*.map
21 | /resources/static/js/publishing/main.js
22 | /resources/static/js/publishing/cljs-runtime
23 | /resources/static/js/publishing/manifest.edn
24 | /resources/static/style.css
25 | /resources/static/js/publishing/code-editor.js
26 | /resources/static/js/code-editor.js
27 | /resources/static/js/module-loader.edn
28 | /resources/static/js/module-loader.json
29 | /resources/static/js/publishing/module-loader.edn
30 | /resources/static/js/publishing/module-loader.json
31 | tauri-release/
32 |
33 | .cpcache/
34 | /target
35 | /checkouts
36 | /src/gen
37 |
38 | *.iml
39 | *.log
40 | .shadow-cljs
41 | .idea
42 | .lein-*
43 | .nrepl-*
44 | .DS_Store
45 | report.html
46 | strings.csv
47 | /resources/static/css/tailwind.min.css
48 | resources/static/style.css
49 |
50 | .calva
51 |
--------------------------------------------------------------------------------
/src/main/app/handler/rss.clj:
--------------------------------------------------------------------------------
1 | (ns app.handler.rss
2 | (:require [app.db.page :as page]
3 | [app.db.user :as u]
4 | [hiccup.page :as html]
5 | [clj-time.core :as t]
6 | [clj-time.coerce :as tc]
7 | [clj-time.format :as tf]
8 | [clojure.string :as string]
9 | [app.config :as config]
10 | [app.util :as util]
11 | [clj-rss.core :as rss]))
12 |
13 | (defn ->rss
14 | [project pages]
15 | (for [{:keys [title html permalink settings published_at]} pages]
16 | {:title title
17 | :description (format "" html)
18 | :link (str config/website-uri "/" project "/" permalink)
19 | :category (string/join ", " (:tags settings))
20 | :pubDate (tc/to-date published_at)}))
21 |
22 | (defn rss-page
23 | [project project-id]
24 | (let [pages (page/get-project-pages-all project-id)]
25 | {:status 200
26 | :body (rss/channel-xml
27 | {:title project
28 | :link (str config/website-uri "/" project)
29 | :description (str "Latest posts from " project)}
30 | (->rss project pages))
31 | :headers {"Content-Type" "application/rss+xml; charset=utf-8"}}))
32 |
--------------------------------------------------------------------------------
/src/main/app/db/user.clj:
--------------------------------------------------------------------------------
1 | (ns app.db.user
2 | (:refer-clojure :exclude [get update])
3 | (:require [toucan.db :as db]
4 | [toucan.models :as model]
5 | [ring.util.response :as resp]
6 | [app.config :as config]
7 | [app.cookie :as cookie]
8 | [app.jwt :as jwt]
9 | [app.db.refresh-token :as refresh-token]))
10 |
11 | (model/defmodel User :users)
12 |
13 | (defn get
14 | [id]
15 | (db/select-one User :id id))
16 |
17 | (defn get-by-name
18 | [name]
19 | (db/select-one User :name name))
20 |
21 | (defn insert
22 | [{:keys [email] :as args}]
23 | (if (nil? email)
24 | (db/insert! User args)
25 | (if-let [user (db/select-one User :email email)]
26 | user
27 | (db/insert! User args))))
28 |
29 | (defn delete
30 | [id]
31 | (db/delete! User :id id))
32 |
33 | (defn update
34 | [id m]
35 | (when id
36 | (db/update! User id m)))
37 |
38 | (defn update-email
39 | [id email]
40 | (when id
41 | (cond
42 | (= (:email (get id)) email)
43 | [:ok true]
44 |
45 | (db/exists? User {:email email})
46 | [:bad :email-address-exists]
47 |
48 | :else
49 | [:ok (db/update! User id {:email email})])))
50 |
51 | (defn generate-tokens
52 | [user-id]
53 | (cookie/token-cookie
54 | {:access-token (jwt/sign {:id user-id})
55 | :refresh-token (refresh-token/create user-id)}))
56 |
--------------------------------------------------------------------------------
/src/main/app/db/util.clj:
--------------------------------------------------------------------------------
1 | (ns app.db.util
2 | (:require [toucan.models :as model]
3 | [cheshire.core :as json]
4 | [clojure.java.jdbc :as j]
5 | [clojure.string :as s]
6 | [clojure.string :as str]
7 | [clj-time.core :as t]
8 | [clj-time.coerce :as tc])
9 | (:import org.postgresql.util.PGobject))
10 |
11 | (defn- clj->pg-json
12 | [value]
13 | (doto (PGobject.)
14 | (.setType "json")
15 | (.setValue (json/generate-string value))))
16 |
17 | (model/add-type! :json
18 | :in clj->pg-json
19 | :out identity)
20 |
21 | (defn generate-enum-keyword
22 | [type value]
23 | {:pre [(every? keyword? [type value])]}
24 | (->> (map name [type value])
25 | (str/join "/")
26 | (keyword)))
27 |
28 | (defn kw->pg-enum [kw]
29 | (let [type (-> (namespace kw)
30 | (s/replace "-" "_"))
31 | value (name kw)]
32 | (doto (PGobject.)
33 | (.setType type)
34 | (.setValue value))))
35 |
36 | (model/add-type! :enum
37 | :in kw->pg-enum
38 | :out identity)
39 |
40 | (extend-protocol j/IResultSetReadColumn
41 | PGobject
42 | (result-set-read-column [pgobj metadata idx]
43 | (let [type (.getType pgobj)
44 | value (.getValue pgobj)]
45 | (case type
46 | "jsonb" (json/parse-string value true)
47 | "enum" value
48 | :else value))))
49 |
50 |
51 | (defn sql-now
52 | []
53 | (tc/to-sql-time (t/now)))
--------------------------------------------------------------------------------
/src/main/app/db/oauth_user.clj:
--------------------------------------------------------------------------------
1 | (ns app.db.oauth-user
2 | (:refer-clojure :exclude [get update])
3 | (:require [toucan.db :as db]
4 | [toucan.models :as model])
5 | (:require [app.db.util :as util]))
6 |
7 | (model/defmodel OauthUser :oauth_users)
8 |
9 | (def source->qualify-kw (partial util/generate-enum-keyword :oauth_source))
10 |
11 | (defn source->pg-object
12 | [source]
13 | (-> (source->qualify-kw source)
14 | (util/kw->pg-enum)))
15 |
16 | (extend (class OauthUser)
17 | model/IModel
18 | (merge model/IModelDefaults
19 | {:types (constantly {:oauth_source :enum})}))
20 |
21 | (defn get
22 | [id]
23 | (db/select-one OauthUser :id id))
24 |
25 | (defn get-by-user-id-from-github
26 | [user-id]
27 | (->
28 | (db/select OauthUser
29 | :user_id user-id
30 | :oauth_source (source->pg-object :github)
31 | {:order-by [[:created_at :desc]]})
32 | (first)))
33 |
34 | (defn get-by-source-&-open-id
35 | [source open-id]
36 | (db/select-one OauthUser
37 | :oauth_source (source->pg-object source)
38 | :open_id open-id))
39 |
40 | (defn get-github-auth
41 | [user-id]
42 | (db/select-one OauthUser
43 | :user_id user-id
44 | :oauth_source (source->pg-object :github)))
45 |
46 | (defn insert
47 | [m]
48 | (let [m (clojure.core/update m :oauth_source source->qualify-kw)]
49 | (db/insert! OauthUser m)))
50 |
51 | (defn update
52 | [id m]
53 | (let [m (if (:oauth_source m)
54 | (clojure.core/update m :oauth_source source->qualify-kw)
55 | m)
56 | m (assoc m :updated_at (util/sql-now))
57 | result (db/update! OauthUser id m)]
58 | (when result
59 | (get id))))
60 |
--------------------------------------------------------------------------------
/src/main/app/s3.clj:
--------------------------------------------------------------------------------
1 | (ns app.s3
2 | (:require [clojure.string :as str]
3 | [clj-time.core :as t]
4 | [clj-time.coerce :as coerce])
5 | (:import
6 | (com.amazonaws AmazonServiceException HttpMethod SdkClientException)
7 | (com.amazonaws.auth AWSStaticCredentialsProvider BasicAWSCredentials)
8 | (com.amazonaws.client.builder AwsClientBuilder$EndpointConfiguration)
9 | (com.amazonaws.services.s3 AmazonS3ClientBuilder)
10 | (com.amazonaws.services.s3.model CannedAccessControlList CreateBucketRequest
11 | DeleteBucketRequest DeleteObjectRequest
12 | GeneratePresignedUrlRequest HeadBucketRequest)))
13 |
14 | (defn s3-client
15 | [key secret]
16 | (let [endpoint (AwsClientBuilder$EndpointConfiguration. "s3.amazonaws.com" "us-east-1")
17 | credentials (AWSStaticCredentialsProvider. (BasicAWSCredentials. key secret))]
18 | (-> (AmazonS3ClientBuilder/standard)
19 | (.withEndpointConfiguration endpoint)
20 | (.withPathStyleAccessEnabled true)
21 | (.withCredentials credentials)
22 | .build)))
23 |
24 | (defn- http-method [method]
25 | (-> method name str/upper-case HttpMethod/valueOf))
26 |
27 | (defn generate-presigned-url
28 | [access-key access-secret bucket key]
29 | (let [method (HttpMethod/valueOf "PUT")
30 | request (-> (GeneratePresignedUrlRequest. bucket key)
31 | (.withExpiration (coerce/to-date (-> 10 t/minutes t/from-now)))
32 | (.withMethod method))]
33 | (.generatePresignedUrl (s3-client access-key access-secret) request)))
34 |
35 | (System/setProperty "SDKGlobalConfiguration.ENABLE_S3_SIGV4_SYSTEM_PROPERTY" "true")
36 |
--------------------------------------------------------------------------------
/src/main/app/result.clj:
--------------------------------------------------------------------------------
1 | (ns app.result)
2 |
3 | (defrecord Result [success? data])
4 | (defn make-result [success? data] (Result. success? data))
5 | (defn result? [result] (instance? Result result))
6 |
7 | (defn success
8 | ([]
9 | (make-result true nil))
10 | ([data]
11 | (make-result true data)))
12 | (defn failed [data] (make-result false data))
13 |
14 | (defn failed? [result] (and (result? result) (not (:success? result))))
15 | (def success? (complement failed?))
16 |
17 | (defmacro check-r
18 | "Return Result when encounter a failed Result or continue executing the next clause.
19 | Usage:
20 |
21 | (check-r
22 | (success :clause-one)
23 | (failed :clause-two)
24 | (success :clause-three))
25 |
26 | => #app.result.Result{:success? false, :data :clause-two}
27 |
28 | Also see app.result-test/test-check-r
29 | "
30 | [f & r]
31 | (let [s (gensym)]
32 | `(let [~s ~f]
33 | (if (failed? ~s)
34 | ~s
35 | ~(if (seq r)
36 | `(check-r ~@r)
37 | s)))))
38 |
39 | (defmacro let-r
40 | "Return Result when encounter a failed Result or continue executing the next clause.
41 | Usage:
42 |
43 |
44 | (let-r [a (success :clause-one)
45 | b (failed :clause-two)]
46 | (success :clause-three))
47 |
48 | => #app.result.Result{:success? false, :data :clause-two}
49 |
50 | Also see app.result-test/test-rlet
51 | "
52 | [bindings & body]
53 | (assert (even? (count bindings)) "Binding element numbers should match even?.")
54 | (let [[f s & r] bindings]
55 | `(let [v# ~s]
56 | (if (failed? v#)
57 | v#
58 | (let [~f (if (result? v#) (:data v#) v#)]
59 | ~(if (seq r)
60 | `(let-r ~(vec r) ~@body)
61 | `(do ~@body)))))))
62 |
--------------------------------------------------------------------------------
/test/app/result_test.clj:
--------------------------------------------------------------------------------
1 | (ns app.result-test
2 | (:require [clojure.test :refer :all])
3 | (:require [app.result :refer :all]))
4 |
5 | (deftest test-check-r
6 | (testing "test normal usage."
7 | (let [r (check-r
8 | (success :clause-one)
9 | (success :clause-two)
10 | (success :clause-three))
11 | expect (success :clause-three)]
12 | (is (= expect r))))
13 |
14 | (testing "return when encounter a false-result"
15 | (let [r (check-r
16 | (success :clause-one)
17 | (failed :clause-two)
18 | (success :clause-three))
19 | expect (failed :clause-two)]
20 | (is (= expect r))))
21 |
22 | (testing "non-false-result value is treated as true."
23 | (let [r (check-r
24 | (success :clause-one)
25 | 3.14
26 | (success :clause-three))
27 | expect (success :clause-three)]
28 | (is (= expect r)))))
29 |
30 | (deftest test-let-r
31 | (testing "all bindings are true-result"
32 | (let [r (let-r [a (success :clause-one)
33 | b (success :clause-two)]
34 | [a b])
35 | expect [:clause-one :clause-two]]
36 | (is (= expect r))))
37 |
38 | (testing "return when encounter a false-result"
39 | (let [r (let-r [a (success :clause-one)
40 | b (failed :clause-two)]
41 | (success :clause-three))
42 | expect (failed :clause-two)]
43 | (is (= expect r))))
44 |
45 | (testing "non-false-result value is treated as true."
46 | (let [r (let-r [a (success :clause-one)
47 | b 3.14
48 | c (success :clause-three)]
49 | [a b c])
50 | expect [:clause-one
51 | 3.14
52 | :clause-three]]
53 | (is (= expect r)))))
54 |
55 |
56 |
--------------------------------------------------------------------------------
/resources/logback.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
6 |
7 |
8 |
9 |
10 |
11 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} %X{io.pedestal} - %msg%n
12 |
13 |
14 |
15 |
16 | logs/logseq-api-%d{yyyy-MM-dd}.%i.log
17 |
18 | 64 MB
19 |
20 |
21 |
22 | true
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | %-5level %logger{36} %X{io.pedestal} - %msg%n
31 |
32 |
33 |
34 | INFO
35 |
36 |
37 |
38 |
39 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/resources/config.edn:
--------------------------------------------------------------------------------
1 | {:env #or [#env ENVIRONMENT "dev"]
2 | :port #or [#env PORT 3000]
3 | :website-uri #or [#env WEBSITE_URL "https://logseq.com"]
4 | :asset-uri #or [#env ASSET_URI "https://asset.logseq.com"]
5 | :cdn-uri #or [#env CDN_URI "cdn.logseq.com"]
6 | :cookie-domain #or [#env COOKIE_DOMAIN ".logseq.com"]
7 | :oauth {:github-app {:app-name #or [#env GITHUB_APP2_NAME "logseq-test"]
8 | :app-id #env GITHUB_APP2_ID
9 | :app-key #env GITHUB_APP2_KEY
10 | :app-secret #env GITHUB_APP2_SECRET
11 | :redirect-uri #env GITHUB_REDIRECT_URI
12 | :app-private-key-pem #env GITHUB_APP_PEM}
13 | :google {:app-key #env GOOGLE_APP_KEY
14 | :app-secret #env GOOGLE_APP_SECRET
15 | :redirect-uri #env GOOGLE_REDIRECT_URI}}
16 | :aws {:pk-path #env AWS_PK_PATH
17 | :key-pair-id #env AWS_KEY_PAIR_ID
18 | :access-key-id #env AWS_ACCESS_KEY_ID
19 | :secret-access-key #env AWS_SECRET_ACCESS_KEY
20 | :bucket #or [#env AWS_BUCKET "logseq-private"]
21 | :region #env AWS_REGION}
22 | :jwt-secret #env JWT_SECRET
23 | :cookie-secret #env COOKIE_SECRET
24 | :log-path #or [#env LOG_PATH "/app/logs/logseq"]
25 | :hikari-spec {:auto-commit true
26 | :read-only false
27 | :connection-timeout 30000
28 | :validation-timeout 5000
29 | :idle-timeout 600000
30 | :max-lifetime 1800000
31 | :minimum-idle 10
32 | :maximum-pool-size 48
33 | :pool-name "logseq-clj-db-pool"
34 | :adapter "postgresql"
35 | :username #env PG_USERNAME
36 | :password #env PG_PASSWORD
37 | :database-name "logseq"
38 | :server-name #or [#env PG_HOST "localhost"]
39 | :port-number #or [#env PG_PORT 5432]
40 | :register-mbeans false
41 | :connection-init-sql "set time zone 'UTC'"}
42 | :slack {:webhooks {:new-user #env LOGSEQ_SLACK_NEW_WEBHOOK
43 | :exception #env LOGSEQ_SLACK_EXCEPTION_WEBHOOK}}}
44 |
--------------------------------------------------------------------------------
/src/main/app/aws.clj:
--------------------------------------------------------------------------------
1 | (ns app.aws
2 | (:require [cognitect.aws.client.api :as aws]
3 | [cognitect.aws.util :as util]
4 | [clj-time.core :as t]
5 | [clj-time.coerce :as tc]
6 | [app.config :as config])
7 | (:import [com.amazonaws.services.cloudfront CloudFrontUrlSigner]
8 | [java.io File]
9 | [java.util Date]
10 | com.amazonaws.services.cloudfront.util.SignerUtils))
11 |
12 | (def cloudfront (aws/client {:api :cloudfront}))
13 |
14 | (def ops (aws/ops cloudfront))
15 |
16 | (defn iso8601-date
17 | ([] (iso8601-date (Date.)))
18 | ([d] (->> (util/format-date util/iso8601-date-format d)
19 | (util/parse-date util/iso8601-date-format))))
20 |
21 | (defn get-signed-url-with-canned-policy
22 | (^java.lang.String [protocol
23 | ^java.lang.String distribution-domain
24 | ^java.io.File private-key-file
25 | ^java.lang.String s-3-object-key
26 | ^java.lang.String key-pair-id
27 | ^java.util.Date date-less-than]
28 | (let [protocol (case protocol
29 | :http
30 | com.amazonaws.services.cloudfront.util.SignerUtils$Protocol/http
31 | :https
32 | com.amazonaws.services.cloudfront.util.SignerUtils$Protocol/https
33 | ;; rtmp
34 | com.amazonaws.services.cloudfront.util.SignerUtils$Protocol/rtmp)]
35 | (CloudFrontUrlSigner/getSignedURLWithCannedPolicy
36 | protocol distribution-domain private-key-file s-3-object-key key-pair-id date-less-than))))
37 |
38 | (defn get-signed-url
39 | [s3-object-key expire-minutes]
40 | (let [{:keys [pk-path key-pair-id]} (:aws config/config)
41 | domain config/cdn-uri
42 | private-key-file-path pk-path
43 | private-key-file (java.io.File. private-key-file-path)
44 | s3-object-key s3-object-key
45 | key-pair-id key-pair-id
46 | date-less-than (->
47 | (tc/to-date (t/plus (t/now) (t/minutes expire-minutes)))
48 | (iso8601-date))]
49 | (get-signed-url-with-canned-policy
50 | :https domain private-key-file s3-object-key
51 | key-pair-id date-less-than)))
52 |
--------------------------------------------------------------------------------
/src/main/app/slack.clj:
--------------------------------------------------------------------------------
1 | (ns app.slack
2 | (:require
3 | [clj-http.client :as client]
4 | [cheshire.core :refer [generate-string]]
5 | [clojure.string :as str]
6 | [taoensso.timbre :as t]
7 | [app.config :as config]))
8 |
9 | ;; Replaced httpkit with clj-http because of a dokku weird bug Unsupported record version Unknown-0.0
10 | (defn slack-escape [message]
11 | "Escape message according to slack formatting spec."
12 | (str/escape message {\< "<" \> ">" \& "&"}))
13 |
14 | (defn send-msg
15 | ([hook msg]
16 | (send-msg hook msg nil))
17 | ([hook msg {:keys [specific-user]}]
18 | (when-not config/dev?
19 | (let [body {"text" (slack-escape msg)}
20 | body (if specific-user
21 | (assoc body "channel" (str "@" specific-user))
22 | body)]
23 | (client/post hook
24 | {:headers {"content-type" "application/json"
25 | "accept" "application/json"}
26 | :body (generate-string body)})))))
27 |
28 | (def rules {:new-user (get-in config/config [:slack :webhooks :new-user])
29 | :exception (get-in config/config [:slack :webhooks :exception])
30 | ;; :api-latency {:webhook (:slack-hook config)}
31 | })
32 |
33 | (defn at-prefix
34 | [msg]
35 | (str "[Logseq] @channel\n" msg))
36 |
37 | (defn notify
38 | [channel msg]
39 | (let [msg (at-prefix msg)]
40 | (future (send-msg (get rules channel) msg))))
41 |
42 | (defn new-exception
43 | [msg]
44 | (notify :exception msg))
45 |
46 | (defn new-user
47 | [msg]
48 | (notify :new-user msg))
49 |
50 | (defn to-string
51 | [& messages]
52 | (let [messages (cons (format "Environment: %s" (if config/production? "Production" "Dev")) messages)]
53 | (->> (map
54 | #(if (isa? (class %) Exception)
55 | (str % "\n\n"
56 | (apply str (interpose "\n" (.getStackTrace %))))
57 | (str %))
58 | messages)
59 | (interpose "\n")
60 | (apply str))))
61 |
62 | (defmacro error
63 | "Log errors, then push to slack,
64 | first argument could be throwable."
65 | [& messages]
66 | `(do
67 | (t/error ~@messages)
68 | (new-exception (to-string ~@messages))))
69 |
70 | (defmacro debug
71 | "Debug and then push to slack"
72 | [& messages]
73 | `(do
74 | (t/debug ~@messages)
75 | (new-exception (to-string ~@messages))))
76 |
--------------------------------------------------------------------------------
/src/main/app/interceptor/etag.clj:
--------------------------------------------------------------------------------
1 | (ns app.interceptor.etag
2 | (:require [clojure.string :as str]
3 | [io.pedestal.interceptor :refer [interceptor]]
4 | [io.pedestal.http.ring-middlewares :as ring-middlewares])
5 | (:import (java.io File)
6 | (clojure.lang PersistentArrayMap)))
7 |
8 | (defn- to-hex-string [bytes]
9 | (str/join "" (map #(Integer/toHexString (bit-and % 0xff))
10 | bytes)))
11 |
12 | (defn sha1 [obj]
13 | (let [bytes (.getBytes (with-out-str (pr obj)))]
14 | (to-hex-string (.digest (java.security.MessageDigest/getInstance "SHA1") bytes))))
15 |
16 | (defonce etag-name "ETag")
17 |
18 | (defmulti calculate-etag class)
19 |
20 | (defmethod calculate-etag String [s]
21 | (sha1 s))
22 |
23 | (defmethod calculate-etag File [f]
24 | (str (.lastModified f) "-" (.length f)))
25 |
26 | (defmethod calculate-etag java.io.InputStream [is]
27 | (let [baos (java.io.ByteArrayOutputStream.)]
28 | (clojure.java.io/copy is baos)
29 | {:sha1 (sha1 (.toString baos))
30 | :is (java.io.BufferedInputStream. (java.io.ByteArrayInputStream. (.toByteArray baos)))}))
31 |
32 | (defmethod calculate-etag :default [x]
33 | nil)
34 |
35 | (defn- not-modified [etag]
36 | {:status 304 :body "" :headers {etag-name etag}})
37 |
38 | (def etag-interceptor
39 | (interceptor
40 | {:name ::etag
41 | :leave (ring-middlewares/response-fn-adapter
42 | (fn [response req]
43 | (let [{body :body
44 | status :status
45 | {etag etag-name} :headers
46 | :as resp} response
47 | if-none-match (get-in req [:headers "if-none-match"])]
48 | (if (and etag (not= status 304))
49 | (if (= etag if-none-match)
50 | (not-modified etag)
51 | resp)
52 | (let [new-etag (calculate-etag body)
53 | is? (and (map? new-etag)
54 | (:is new-etag))
55 | etag' (if (map? new-etag)
56 | (:sha1 new-etag)
57 | new-etag)]
58 | (if (and etag' (= etag' if-none-match))
59 | (not-modified etag')
60 | (cond->
61 | (assoc-in resp [:headers etag-name] etag')
62 | is?
63 | (assoc :body (:is new-etag)))))))))}))
64 |
--------------------------------------------------------------------------------
/src/dev/user.clj:
--------------------------------------------------------------------------------
1 | (ns user
2 | (:require [com.stuartsierra.component :as component]
3 | [clojure.tools.namespace.repl :as namespace]
4 | [app.config :as config]
5 | [app.db-migrate :as migrate]
6 | [clj-time
7 | [coerce :as tc]
8 | [core :as t]]
9 | [clojure.java.io :as io]
10 | [clojure.string :as string]))
11 |
12 | (namespace/disable-reload!)
13 | (namespace/set-refresh-dirs "src/main" "src/dev")
14 | (defonce *system (atom nil))
15 | (defonce *db (atom nil))
16 |
17 | (defn migrate []
18 | (migrate/migrate @*db))
19 |
20 | (defn rollback []
21 | (migrate/rollback @*db))
22 |
23 | (defn stop []
24 | (some-> @*system (component/stop))
25 | (reset! *system nil))
26 |
27 | (defn refresh []
28 | (let [res (namespace/refresh)]
29 | (when (not= res :ok)
30 | (throw res))
31 | :ok))
32 |
33 | (defn go
34 | []
35 | (require 'app.core)
36 | ;; (dev/watch)
37 | (when-some [f (resolve 'app.system/new-system)]
38 | (when-some [system (f config/config)]
39 | (when-some [system' (component/start system)]
40 | (reset! *system system')
41 | (reset! *db {:datasource (get-in @*system [:hikari :datasource])}))))
42 | (migrate))
43 |
44 | (defn reset []
45 | (stop)
46 | (refresh)
47 | (go))
48 |
49 | (defn get-unix-timestamp []
50 | (tc/to-long (t/now)))
51 |
52 | (def date-format
53 | "Format for DateTime"
54 | "yyyyMMddHHmmss")
55 | (def migrations-dir
56 | "Default migrations directory"
57 | "resources/migrations/")
58 | (def ragtime-format-edn
59 | "EDN template for SQL migrations"
60 | "{:up [\"\"]\n :down [\"\"]}")
61 |
62 | (defn migrations-dir-exist?
63 | "Checks if 'resources/migrations' directory exists"
64 | []
65 | (.isDirectory (io/file migrations-dir)))
66 |
67 | (defn now
68 | "Gets the current DateTime" []
69 | (.format (java.text.SimpleDateFormat. date-format) (new java.util.Date)))
70 |
71 | (defn migration-file-path
72 | "Complete migration file path"
73 | [name]
74 | (str migrations-dir (now) "_" (string/replace name #"\s+|-+|_+" "_") ".edn"))
75 |
76 | (defn create-migration
77 | "Creates a migration file with the current DateTime"
78 | [name]
79 | (let [migration-file (migration-file-path name)]
80 | (if-not (migrations-dir-exist?)
81 | (io/make-parents migration-file))
82 | (spit migration-file ragtime-format-edn)))
83 |
84 | (defn reset-db
85 | []
86 | (dotimes [i 100]
87 | (rollback))
88 | (migrate))
89 |
--------------------------------------------------------------------------------
/resources/js/magic_portal.js:
--------------------------------------------------------------------------------
1 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):e.MagicPortal=t()}(this,function(){var e=function(e){var t=this;this.rpc_counter=0,this.channel=e,this.foreign=new Map,this.local=new Map,this.calls=new Map,this.queue=[],this.connectionEstablished=!1,this.channel.addEventListener("message",function(e){var n=e.data;if(n&&"object"==typeof n)switch(n.type){case"MP_INIT":return t.onInit(n);case"MP_SET":return t.onSet(n);case"MP_CALL":return t.onCall(n);case"MP_RETURN":return t.onReturn(n)}}),this.channel.postMessage({type:"MP_INIT",id:1,reply:!0})};e.prototype.onInit=function(e){this.connectionEstablished=!0;var t=this.queue;this.queue=[];for(var n=0,o=t;n
26 | {:value (sign value)
27 | :max-age max-age
28 | :http-only true
29 | :path path
30 | :secure secure
31 | ;; :same-site "Strict"
32 | }
33 | domain
34 | (assoc :domain domain))
35 | "xsrf-token" (cond->
36 | {:value xsrf-token
37 | :max-age max-age
38 | :http-only true
39 | :path "/"
40 | :secure secure
41 | ;; :same-site "Strict"
42 | }
43 | domain
44 | (assoc :domain domain))}))
45 |
46 | (def delete-token
47 | (let [domain (if-not config/dev?
48 | config/cookie-domain
49 | "")
50 | delete-value {:value ""
51 | :path "/"
52 | :expires "Thu, 01 Jan 1970 00:00:00 GMT"
53 | :http-only true
54 | :max-age 0
55 | :domain domain}]
56 | {"x" delete-value
57 | "xsrf-token" delete-value
58 | "spa" delete-value}))
59 |
60 | (def spa
61 | (let [domain (if-not config/dev?
62 | config/cookie-domain
63 | "")
64 | max-age (* (* 3600 24) 60)]
65 | {"spa" {:value "true"
66 | :path "/"
67 | :max-age max-age
68 | :http-only true
69 | :domain domain}}))
70 |
71 | (defn get-tokens [req]
72 | (when-let [x (get-in req [:cookies "x" :value])]
73 | (try
74 | (unsign x)
75 | (catch Exception e
76 | (slack/debug "Cookie exception: "
77 | {:token x
78 | :e e})))))
79 |
--------------------------------------------------------------------------------
/src/main/app/http.clj:
--------------------------------------------------------------------------------
1 | (ns app.http
2 | (:require [app.result :as r]
3 | [schema.utils :as utils]))
4 |
5 | (defn- failed
6 | ([http-code error-message]
7 | (failed http-code error-message nil))
8 | ([http-code error-message opts]
9 | (let [m {:status http-code :result error-message}]
10 | (r/failed (merge m opts)))))
11 |
12 | (defn redirect
13 | [url & {:as opts}]
14 | (let [data {:status 302
15 | :headers {"Location" url}
16 | :body ""}
17 | data (merge data opts)]
18 | (r/failed data)))
19 |
20 | (defn bad-request
21 | ([]
22 | (bad-request "Bad Request."))
23 | ([message & {:as http-opts}]
24 | (failed 400 message http-opts)))
25 |
26 | (defn unauthorized
27 | ([]
28 | (unauthorized "Unauthorized."))
29 | ([message & {:as http-opts}]
30 | (failed 401 message http-opts)))
31 |
32 | (defn forbidden
33 | ([]
34 | (forbidden "Forbidden."))
35 | ([message & {:as http-opts}]
36 | (failed 403 message http-opts)))
37 |
38 | (defn not-found
39 | ([]
40 | (not-found "Not Found."))
41 | ([message & {:as http-opts}]
42 | (failed 404 message http-opts)))
43 |
44 | (defn internal-server-error
45 | ([]
46 | (internal-server-error "Internal Server Error."))
47 | ([message & {:as http-opts}]
48 | (failed 500 message http-opts)))
49 |
50 | (defn success
51 | ([data]
52 | (let [status 200
53 | data {:status status :result data}]
54 | (r/success data)))
55 | ([status data & {:as http-opts}]
56 | (let [data (merge {:status status :result data} http-opts)]
57 | (r/success data))))
58 |
59 | (defn result->http-map
60 | [result]
61 | {:pre [(r/result? result)]}
62 | (let [data (:data result)]
63 | (let [{:keys [status headers result cookies]} data
64 | body (if (r/failed? result)
65 | {:message result}
66 | {:result result})
67 | m {:status status :body body}]
68 | (utils/assoc-when m
69 | :headers headers
70 | :cookies cookies))))
71 |
72 | (defn result->old-http-map
73 | "Deprecated: This function is only for compatibility with the old fashion result. New API should use result->http-map."
74 | [result]
75 | {:pre [(r/result? result)]}
76 | (let [data (:data result)]
77 | (let [{:keys [status headers result cookies]} data
78 | body (if (r/failed? result)
79 | {:message result}
80 | result)
81 | m {:status status :body body}]
82 | (utils/assoc-when m
83 | :headers headers
84 | :cookies cookies))))
85 |
--------------------------------------------------------------------------------
/project.clj:
--------------------------------------------------------------------------------
1 | (defproject logseq "0.1.0-SNAPSHOT"
2 | :description "Knowledge base tool"
3 | :url "https://logseq.com"
4 | :license {:name "EPL-2.0 OR GPL-2.0-or-later WITH Classpath-exception-2.0"
5 | :url "https://www.eclipse.org/legal/epl-2.0/"}
6 | :dependencies [[org.clojure/clojure "1.10.0"]
7 | [org.clojure/tools.nrepl "0.2.12"]
8 | [clj-social "0.1.9"]
9 | [org.postgresql/postgresql "42.2.8"]
10 | [org.clojure/java.jdbc "0.7.10"]
11 | [honeysql "0.9.8"]
12 | [hikari-cp "2.9.0"]
13 | [toucan "1.15.0"]
14 | [ragtime "0.8.0"]
15 | [org.clojure/tools.namespace "0.3.1"]
16 | [buddy/buddy-sign "3.1.0"]
17 | [buddy/buddy-hashers "1.4.0"]
18 | [io.pedestal/pedestal.service "0.5.5"]
19 | [io.pedestal/pedestal.jetty "0.5.5"]
20 | [metosin/reitit-pedestal "0.4.2"]
21 | [metosin/reitit "0.4.2"]
22 | [metosin/jsonista "0.2.5"]
23 | [aero "1.1.6"]
24 | [com.stuartsierra/component "0.4.0"]
25 | [com.taoensso/nippy "2.14.0"]
26 | [hiccup "2.0.0-alpha2"]
27 | [hickory "0.7.1"]
28 | [com.amazonaws/aws-java-sdk-cloudfront "1.11.744"]
29 | [com.amazonaws/aws-java-sdk-s3 "1.11.744"]
30 | [com.cognitect.aws/api "0.8.456"]
31 | [com.cognitect.aws/endpoints "1.1.11.753"]
32 | [com.cognitect.aws/s3 "784.2.593.0"]
33 | [com.cognitect.aws/cloudfront "789.2.611.0"]
34 | [lambdaisland/uri "1.2.1"]
35 | [com.fzakaria/slf4j-timbre "0.3.19"]
36 | [clj-http "3.10.1"]
37 | [clj-rss "0.2.5"]
38 | [org.clojure/core.cache "1.0.207"]
39 | [clj-time "0.15.2"]]
40 | :plugins [[lein-cljfmt "0.7.0"]]
41 | :main app.core
42 | :source-paths ["src/main"]
43 | :profiles {:repl {:dependencies [[io.pedestal/pedestal.service-tools "0.5.7"]]
44 | :source-paths ["src/main" "src/dev"]}
45 | :uberjar {:main app.core
46 | :aot :all
47 | :omit-source true
48 | :uberjar-name "logseq.jar"}}
49 | :repl-options {:init-ns user}
50 | :jvm-opts ["-Duser.timezone=UTC" "-Dclojure.spec.check-asserts=true"]
51 | :aliases {"migrate" ["run" "-m" "user/migrate"]
52 | "rollback" ["run" "-m" "user/rollback"]}
53 | :min-lein-version "2.0.0"
54 | :cljfmt {:paths ["src" "test" "web/src"]})
55 |
--------------------------------------------------------------------------------
/src/main/app/db/repo.clj:
--------------------------------------------------------------------------------
1 | (ns app.db.repo
2 | (:refer-clojure :exclude [get update])
3 | (:require [toucan.db :as db]
4 | [toucan.models :as model]
5 | [ring.util.response :as resp]
6 | [app.config :as config]
7 | [app.cookie :as cookie]
8 | [clojure.java.jdbc :as j]
9 | [clojure.string :as string]))
10 |
11 | (model/defmodel Repo :repos)
12 |
13 | (defn get
14 | [id]
15 | (db/select-one Repo :id id))
16 |
17 | (defn get-by-user-id-and-url
18 | [user-id url]
19 | (db/select-one Repo
20 | :user_id user-id
21 | :url url))
22 |
23 | (defn insert
24 | [args]
25 | (cond
26 | (and
27 | (:user_id args) (:url args)
28 | (db/exists? Repo (select-keys args [:user_id :url])))
29 | (get-by-user-id-and-url (:user_id args) (:url args))
30 |
31 | :else
32 | (db/insert! Repo args)))
33 |
34 | (defn get-user-repos
35 | [user-id]
36 | (db/select Repo :user_id user-id))
37 |
38 | (defn delete
39 | [id]
40 | (when id
41 | (db/delete! Repo :id id)))
42 |
43 | (defn update
44 | [id m]
45 | (db/update! Repo id m))
46 |
47 | (defn update-branch-by-user-id-and-url
48 | [user-id url branch]
49 | (db/update-where! Repo {:user_id user-id
50 | :url url}
51 | :branch branch))
52 |
53 | (defn belongs-to?
54 | [repo-id user-id]
55 | (= user-id (:user_id (get repo-id))))
56 |
57 | (defn get-repo-users
58 | [id]
59 | (let [url (:url (get id))]
60 | (j/query (db/connection)
61 | ["select u.name, u.avatar, u.website from repos r left join users u on r.user_id = u.id where r.url = ?"
62 | url])))
63 |
64 | (defn update-installation-id!
65 | [installation-id repos]
66 | (when (and installation-id (seq repos))
67 | (j/execute! (db/connection)
68 | (format
69 | "Update repos set installation_id = %s where url IN (%s)"
70 | (format "'%s'" installation-id)
71 | (string/join ", "
72 | (map
73 | (fn [repo]
74 | (format "'%s'" repo))
75 | repos))))))
76 |
77 | (defn update-repo-installation-id!
78 | [repo installation-id]
79 | (when (and repo installation-id)
80 | (j/update! (db/connection)
81 | :repos
82 | {:installation_id installation-id}
83 | ["url = ?" repo])))
84 |
85 | (defn clear-user-repos!
86 | [user-id]
87 | (db/delete! Repo :user_id user-id))
88 |
89 | (defn get-user-main-branch-repos
90 | [user-id]
91 | (db/select Repo
92 | :user_id user-id
93 | :branch "main"))
94 |
--------------------------------------------------------------------------------
/deps.edn:
--------------------------------------------------------------------------------
1 | {:paths ["src/main" "resources"]
2 | :deps
3 | {org.clojure/clojure {:mvn/version "1.10.0"}
4 | org.clojure/tools.nrepl {:mvn/version "0.2.12"}
5 | clj-social {:mvn/version "0.1.9"}
6 | hikari-cp {:mvn/version "2.9.0"}
7 | io.pedestal/pedestal.jetty {:mvn/version "0.5.5"}
8 | io.pedestal/pedestal.service {:mvn/version "0.5.5"}
9 | toucan {:mvn/version "1.15.0"}
10 | aero {:mvn/version "1.1.6"}
11 | com.stuartsierra/component {:mvn/version "0.4.0"}
12 | org.postgresql/postgresql {:mvn/version "42.2.8"}
13 | buddy/buddy-hashers {:mvn/version "1.4.0"}
14 | ragtime {:mvn/version "0.8.0"}
15 | org.clojure/java.jdbc {:mvn/version "0.7.10"}
16 | hiccup {:mvn/version "2.0.0-alpha2"}
17 | hickory {:mvn/version "0.7.1"}
18 | metosin/reitit-pedestal {:mvn/version "0.4.2"}
19 | com.taoensso/nippy {:mvn/version "2.14.0"}
20 | buddy/buddy-sign {:mvn/version "3.1.0"}
21 | metosin/jsonista {:mvn/version "0.2.5"}
22 | metosin/reitit {:mvn/version "0.4.2"}
23 | honeysql {:mvn/version "0.9.8"}
24 | clj-rss {:mvn/version "0.2.5"}
25 | clj-time {:mvn/version "0.15.2"}
26 |
27 | ;; s3
28 | com.cognitect.aws/api {:mvn/version "0.8.456"}
29 | com.cognitect.aws/endpoints {:mvn/version "1.1.11.753"}
30 | com.cognitect.aws/s3 {:mvn/version "784.2.593.0"}
31 | com.cognitect.aws/cloudfront {:mvn/version "789.2.611.0"}
32 |
33 | com.amazonaws/aws-java-sdk-cloudfront {:mvn/version "1.11.744"}
34 | com.amazonaws/aws-java-sdk-s3 {:mvn/version "1.11.744"}
35 |
36 | lambdaisland/uri {:mvn/version "1.2.1"}
37 | com.fzakaria/slf4j-timbre {:mvn/version "0.3.19"}
38 | clj-http {:mvn/version "3.10.1"}
39 | org.clojure/core.cache {:mvn/version "1.0.207"}}
40 |
41 |
42 | :aliases {:test
43 | {:extra-paths ["test"]
44 | :extra-deps {org.clojure/test.check {:mvn/version "RELEASE"}}}
45 | :runner
46 | {:extra-deps
47 | {com.cognitect/test-runner
48 | {:git/url "https://github.com/cognitect-labs/test-runner",
49 | :sha "76568540e7f40268ad2b646110f237a60295fa3c"}},
50 | :main-opts ["-m" "cognitect.test-runner" "-d" "test"]}
51 | :run
52 | {:jvm-opts
53 | ["-Duser.timezone=UTC" "-Dclojure.spec.check-asserts=true"],
54 | :main-opts ["-m" "app.core"]}
55 | :dev
56 | {:extra-paths ["src/dev"]
57 | :jvm-opts
58 | ["-Duser.timezone=UTC" "-Dclojure.spec.check-asserts=true"]
59 | :extra-deps
60 | {io.pedestal/pedestal.service-tools {:mvn/version "0.5.7"}
61 | org.clojure/tools.namespace {:mvn/version "0.2.11"}}}
62 |
63 | :uberjar {:extra-deps {uberdeps {:mvn/version "0.1.10"}}
64 | :main-opts ["-m" "uberdeps.uberjar" "--target" "./target/logseq.jar"]}}}
65 |
--------------------------------------------------------------------------------
/src/main/app/db/page.clj:
--------------------------------------------------------------------------------
1 | (ns app.db.page
2 | (:refer-clojure :exclude [get])
3 | (:require [toucan.db :as db]
4 | [clojure.java.jdbc :as jdbc]
5 | [honeysql.core :as h]
6 | [toucan.models :as model]
7 | [ring.util.response :as resp]
8 | [app.config :as config]
9 | [app.cookie :as cookie]
10 | [app.util :as util]
11 | [app.db.util]))
12 |
13 | ;; TODO: safe check
14 | (model/defmodel Page :pages)
15 |
16 | ;; https://github.com/metabase/toucan/issues/58
17 | (extend (class Page)
18 | model/IModel
19 | (merge model/IModelDefaults
20 | {:types (constantly {:settings :json})}))
21 |
22 | (defn get
23 | [page-id]
24 | (db/select-one Page :id page-id))
25 |
26 | (defn get-by-user-id-and-permalink
27 | [user-id permalink]
28 | (db/select-one Page
29 | :user_id user-id
30 | :permalink permalink))
31 |
32 | (defn get-by-project-id-and-permalink
33 | [project-id permalink]
34 | (db/select-one Page
35 | :project_id project-id
36 | :permalink permalink))
37 |
38 | (defn get-all-by-project-id
39 | [project-id]
40 | (db/select Page :project_id project-id))
41 |
42 | (defn belongs-to?
43 | [permalink user-id]
44 | (= user-id
45 | (db/select-one-field :user_id Page :permalink permalink)))
46 |
47 | ;; TODO: upsert
48 | (defn insert
49 | [args]
50 | (cond
51 | (and
52 | (:user_id args) (:permalink args)
53 | (db/exists? Page (select-keys args [:user_id :permalink])))
54 | (let [page-id (db/select-one-field :id Page :permalink (:permalink args))]
55 | (db/update! Page page-id
56 | (-> args
57 | (dissoc :permalink :user_id)
58 | (assoc :updated_at (util/sql-now))))
59 | (get page-id))
60 |
61 | :else
62 | (db/insert! Page args)))
63 |
64 | (defn delete
65 | [id]
66 | (when id
67 | (db/delete! Page :id id)))
68 |
69 | (defn get-project-pages
70 | [project-id]
71 | (db/select [Page :permalink :title :published_at]
72 | :project_id project-id))
73 |
74 | (defn get-project-pages-all
75 | [project-id]
76 | (db/select Page :project_id project-id))
77 |
78 | (defn get-all-tags
79 | [project-id]
80 | (->> (jdbc/query (db/connection)
81 | ["SELECT settings -> 'tags' AS tags FROM pages where project_id = ?;"
82 | project-id])
83 | (map :tags)
84 | (apply concat)
85 | (frequencies)
86 | (sort-by val)
87 | reverse))
88 |
89 | (defn get-pages-by-tag
90 | [project-id tag]
91 | (->> (jdbc/query (db/connection)
92 | ["SELECT permalink, title, published_at, settings->'tags' as tags FROM pages where project_id = ? order by published_at desc;"
93 | project-id])
94 | (filter #(contains? (set (:tags %)) tag))))
95 |
--------------------------------------------------------------------------------
/src/main/app/db/project.clj:
--------------------------------------------------------------------------------
1 | (ns app.db.project
2 | (:refer-clojure :exclude [get update])
3 | (:require [toucan.db :as db]
4 | [clojure.java.jdbc :as jdbc]
5 | [honeysql.core :as h]
6 | [toucan.models :as model]
7 | [ring.util.response :as resp]
8 | [app.config :as config]
9 | [app.cookie :as cookie]
10 | [app.util :as util]
11 | [app.db.util]
12 | [app.db.user :as u]
13 | [app.db.repo :as repo]
14 | [app.db.page :as page]))
15 |
16 | ;; TODO: safe check
17 | (model/defmodel Project :projects)
18 |
19 | (extend (class Project)
20 | model/IModel
21 | (merge model/IModelDefaults
22 | {:types (constantly {:settings :json})}))
23 |
24 | (defn get
25 | [project-id]
26 | (db/select-one Project :id project-id))
27 |
28 | (defn belongs-to?
29 | [name user-id]
30 | (= user-id
31 | (db/select-one-field :user_id Project :name name)))
32 |
33 | (defn insert
34 | [args]
35 | (cond
36 | (and
37 | (:user_id args) (:name args)
38 | (db/exists? Project {:name (:name args)}))
39 | (let [project-id (db/select-one-field :id Project :name (:name args))]
40 | (db/update! Project project-id
41 | (dissoc args :user_id))
42 | (get project-id))
43 |
44 | (:user_id args)
45 | (db/insert! Project args)
46 |
47 | :else
48 | nil))
49 |
50 | (defn get-user-projects
51 | [user-id]
52 | (when user-id
53 | (db/select Project
54 | :user_id user-id)))
55 |
56 | (defn exists?
57 | [name]
58 | (db/exists? Project {:name name}))
59 |
60 | (defn get-id-by-name
61 | [name]
62 | (db/select-one-field :id Project :name name))
63 |
64 | (defn get-user-id-by-name
65 | [name]
66 | (db/select-one-field :user_id Project :name name))
67 |
68 | (defn get-id-by-repo-id
69 | [repo-id]
70 | (db/select-one-field :id Project :repo_id repo-id))
71 |
72 | (defn get-name-by-id
73 | [id]
74 | (db/select-one-field :name Project :id id))
75 |
76 | (defn create-user-default-project
77 | [{:keys [id name] :as user}]
78 | (when-not (exists? name)
79 | (insert {:user_id id
80 | :name name})))
81 |
82 | (defn get-project-info
83 | [project-id]
84 | (let [{:keys [name settings repo_id user_id]} (get project-id)
85 | user (-> (u/get user_id)
86 | (select-keys [:name :avatar :website]))
87 | repo (repo/get repo_id)
88 | repo-users (and repo (repo/get-repo-users repo_id))]
89 | (cond->
90 | {:name name
91 | :settings settings
92 | :creator user
93 | :contributors (remove (fn [u] (= (:name u) (:name user)))
94 | repo-users)}
95 | (:public repo)
96 | (assoc :url (:url repo)))))
97 |
98 | (defn update
99 | [id m]
100 | (db/update! Project id m))
101 |
102 | (defn get-project-pages
103 | [name]
104 | (when-let [project-id (get-id-by-name name)]
105 | (db/select [page/Page :permalink :title :published_at]
106 | :project_id project-id)))
107 |
108 | (defn delete
109 | [id]
110 | (when id
111 | (db/delete! Project :id id)))
--------------------------------------------------------------------------------
/src/main/app/handler/project.clj:
--------------------------------------------------------------------------------
1 | (ns app.handler.project
2 | (:require [clojure.string :as string])
3 | (:require [app.db
4 | [project :as project]]
5 | [app.interceptors :as interceptors]
6 | [app.handler.page :as page-handler]
7 | [app.result :as r]
8 | [app.http :as h]
9 | [app.reserved-routes :as reserved]
10 | [app.db.repo :as repo]
11 | [app.handler.utils :as hu]))
12 |
13 | (defn get-id-by-name [name]
14 | {:pre [(string? name)]}
15 | (if-let [project-id (project/get-id-by-name name)]
16 | (r/success project-id)
17 | (h/not-found)))
18 |
19 | (defn validate-project-name?
20 | [project-name]
21 | (r/check-r
22 | (when (or (string/blank? project-name)
23 | (< (count (string/trim project-name)) 2))
24 | (h/bad-request "Project name should be at least 2 chars."))
25 |
26 | (when (reserved/reserved? project-name)
27 | (h/bad-request "Please change to another name."))
28 |
29 | (when (project/exists? project-name)
30 | (h/bad-request "Please change to another name."))
31 |
32 | (r/success)))
33 |
34 | (defn delete-project
35 | [{:keys [app-context path-params] :as req}]
36 | (let [project-name (:name path-params)]
37 | (r/check-r
38 | (when (string/blank? project-name)
39 | (h/bad-request))
40 | (r/let-r
41 | [user (hu/login? app-context)
42 | project-id (get-id-by-name project-name)
43 | _ (hu/permit-to-access-project? user project-name)]
44 | (project/delete project-id)
45 | (interceptors/clear-user-cache (:id user))
46 | (page-handler/clear-project-cache! project-id)
47 | (h/success true)))))
48 |
49 | (defn update-project
50 | [{:keys [app-context path-params body-params] :as req}]
51 | (let [origin-name (:name path-params)
52 | new-name (:name body-params)
53 | new-settings (:settings body-params)]
54 | (r/let-r [user (hu/login? app-context)
55 | project-id (get-id-by-name origin-name)]
56 | (r/check-r
57 | (hu/permit-to-access-project? user origin-name)
58 |
59 | (when (string? new-name)
60 | (validate-project-name? new-name))
61 |
62 | (let [project (cond-> {}
63 | new-name
64 | (assoc :name new-name)
65 |
66 | new-settings
67 | (assoc :settings new-name))]
68 | (project/update project-id project)
69 | (interceptors/clear-user-cache (:id user))
70 | (page-handler/clear-project-cache! project-id)
71 | (h/success true))))))
72 |
73 | (defn create-project
74 | [{:keys [app-context body-params] :as req}]
75 | (let [project-name (:name body-params)]
76 | (r/let-r [user (hu/login? app-context)
77 | _ (validate-project-name? project-name)]
78 | (let [{:keys [repo settings]} body-params
79 | repo-id (if repo (:id (repo/get-by-user-id-and-url (:id user) repo)))
80 | result (project/insert (cond->
81 | {:user_id (:id user)
82 | :name project-name}
83 | repo-id
84 | (assoc :repo_id repo-id)
85 | settings
86 | (assoc :settings settings)))
87 | body (let [result (select-keys result [:name :settings])]
88 | (assoc result :repo repo))]
89 | (h/success 201 body)))))
90 |
--------------------------------------------------------------------------------
/src/main/app/interceptor/gzip.clj:
--------------------------------------------------------------------------------
1 | (ns app.interceptor.gzip
2 | "Ring gzip compression."
3 | (:require [clojure.java.io :as io]
4 | [io.pedestal.interceptor :refer [interceptor]]
5 | [io.pedestal.http.ring-middlewares :as ring-middlewares])
6 | (:import (java.io InputStream
7 | Closeable
8 | File
9 | PipedInputStream
10 | PipedOutputStream)
11 | (java.util.zip GZIPOutputStream)))
12 |
13 | ;; Copy from bk/ring-gzip
14 |
15 | (defn- accepts-gzip?
16 | [req]
17 | (if-let [accepts (get-in req [:headers "accept-encoding"])]
18 | ;; Be aggressive in supporting clients with mangled headers (due to
19 | ;; proxies, av software, buggy browsers, etc...)
20 | (re-seq
21 | #"(gzip\s*,?\s*(gzip|deflate)?|X{4,13}|~{4,13}|\-{4,13})"
22 | accepts)))
23 |
24 | ;; Set Vary to make sure proxies don't deliver the wrong content.
25 | (defn- set-response-headers
26 | [headers]
27 | (if-let [vary (or (get headers "vary") (get headers "Vary"))]
28 | (-> headers
29 | (assoc "Vary" (str vary ", Accept-Encoding"))
30 | (assoc "Content-Encoding" "gzip")
31 | (dissoc "Content-Length" "content-length")
32 | (dissoc "vary"))
33 | (-> headers
34 | (assoc "Vary" "Accept-Encoding")
35 | (assoc "Content-Encoding" "gzip")
36 | (dissoc "Content-Length" "content-length"))))
37 |
38 | (def ^:private supported-status? #{200, 201, 202, 203, 204, 205 403, 404})
39 |
40 | (defn- unencoded-type?
41 | [headers]
42 | (if (headers "content-encoding")
43 | false
44 | true))
45 |
46 | (defn- supported-type?
47 | [resp]
48 | (let [{:keys [headers body]} resp]
49 | (or (string? body)
50 | (seq? body)
51 | (instance? InputStream body)
52 | (and (instance? File body)
53 | (re-seq #"(?i)\.(htm|html|css|js|json|xml)" (pr-str body))))))
54 |
55 | (def ^:private min-length 859)
56 |
57 | (defn- supported-size?
58 | [resp]
59 | (let [{body :body} resp]
60 | (cond
61 | (string? body) (> (count body) min-length)
62 | (seq? body) (> (count body) min-length)
63 | (instance? File body) (> (.length ^File body) min-length)
64 | :else true)))
65 |
66 | (defn- supported-response?
67 | [resp]
68 | (let [{:keys [status headers]} resp]
69 | (and (supported-status? status)
70 | (unencoded-type? headers)
71 | (supported-type? resp)
72 | (supported-size? resp))))
73 |
74 | (defn- compress-body
75 | [body]
76 | (let [p-in (PipedInputStream.)
77 | p-out (PipedOutputStream. p-in)]
78 | (future
79 | (with-open [out (GZIPOutputStream. p-out)]
80 | (if (seq? body)
81 | (doseq [string body] (io/copy (str string) out))
82 | (io/copy body out)))
83 | (when (instance? Closeable body)
84 | (.close ^Closeable body)))
85 | p-in))
86 |
87 | (defn- gzip-response
88 | [resp]
89 | (-> resp
90 | (update-in [:headers] set-response-headers)
91 | (update-in [:body] compress-body)))
92 |
93 | (def gzip-interceptor
94 | "Middleware that compresses responses with gzip for supported user-agents."
95 | (interceptor
96 | {:name ::gzip
97 | :leave (ring-middlewares/response-fn-adapter
98 | (fn [resp req]
99 | (if (and (accepts-gzip? req)
100 | (supported-response? resp))
101 | (gzip-response resp)
102 | resp)))}))
103 |
--------------------------------------------------------------------------------
/src/main/app/interceptors.clj:
--------------------------------------------------------------------------------
1 | (ns app.interceptors
2 | (:require [io.pedestal.interceptor :refer [interceptor]]
3 | [io.pedestal.interceptor.helpers :as helpers]
4 | [io.pedestal.http.ring-middlewares :as ring-middlewares]
5 | [app.cookie :as cookie]
6 | [app.jwt :as jwt]
7 | [app.db.user :as u]
8 | [app.db.repo :as repo]
9 | [app.db.project :as project]
10 | [app.util :as util]
11 | [ring.util.response :as resp]
12 | [app.config :as config]
13 | [app.interceptor.etag :as etag]
14 | [app.interceptor.gzip :as gzip]
15 | [app.slack :as slack]))
16 |
17 | ;; move to a separate handler helper
18 | (defn logout
19 | [_req]
20 | (-> (resp/redirect config/website-uri)
21 | (assoc :cookies cookie/delete-token)))
22 |
23 | (defonce users-cache
24 | (atom {}))
25 |
26 | (defn clear-user-cache
27 | [user-id]
28 | (swap! users-cache dissoc user-id))
29 |
30 | (def cookie-interceptor
31 | (interceptor
32 | {:name ::cookie-authenticate
33 | :enter
34 | (fn [{:keys [request] :as context}]
35 | (let [tokens (cookie/get-tokens request)]
36 | (if tokens
37 | (let [{:keys [access-token refresh-token]} tokens]
38 | (if access-token
39 | (try
40 | (let [{:keys [id access-token]} (jwt/unsign access-token)
41 | uid (some-> id util/->uuid)
42 | user (when-let [user (u/get uid)]
43 | (let [repos (map #(select-keys % [:id :url :branch :installation_id])
44 | (repo/get-user-repos uid))
45 | projects (map
46 | (fn [project]
47 | (let [repo-id (:repo_id project)
48 | project (select-keys project [:name :description])]
49 | (assoc project :repo
50 | (:url (first (filter (fn [repo] (= (:id repo) repo-id)) repos))))))
51 | (project/get-user-projects uid))
52 | user (assoc user
53 | :repos repos
54 | :projects projects)
55 | user (assoc user :preferred_format
56 | (:preferred_format user))]
57 | (swap! users-cache assoc uid user)
58 | user))]
59 | (if (:id user)
60 | (-> context
61 | (assoc-in [:request :app-context :uid] uid)
62 | (assoc-in [:request :app-context :user] (assoc user :access-token access-token)))
63 | context))
64 | (catch Exception e ; token is expired
65 | (when (= (ex-data e)
66 | {:type :validation, :cause :exp})
67 | (slack/debug "Jwt token expired: " {:token access-token
68 | :e e})
69 | (assoc context :response (logout request)))))))
70 | context)))}))
71 |
72 | (defn cache-control
73 | [max-age]
74 | {:name ::cache-control
75 | :leave (fn [{:keys [response] :as context}]
76 | (let [{:keys [status headers]} response
77 | response (if (= 200 status)
78 | (let [val (if (= :no-cache max-age)
79 | "no-cache"
80 | (str "public, max-age=" max-age))]
81 | (assoc-in response [:headers "Cache-Control"] val))
82 | response)]
83 | (assoc context :response response)))})
84 |
85 | (def etag-interceptor etag/etag-interceptor)
86 | (def gzip-interceptor gzip/gzip-interceptor)
87 |
--------------------------------------------------------------------------------
/src/main/app/util.clj:
--------------------------------------------------------------------------------
1 | (ns app.util
2 | (:require [clojure.string :as str]
3 | [clj-time
4 | [coerce :as tc]
5 | [core :as t]
6 | [format :as tf]]
7 | [app.config :as config]
8 | [ring.util.codec :as codec])
9 | (:import [java.util UUID]
10 | [java.util TimerTask Timer]))
11 | (defn uuid
12 | "Generate uuid."
13 | []
14 | (UUID/randomUUID))
15 |
16 | (defn ->uuid
17 | [s]
18 | (if (uuid? s)
19 | s
20 | (UUID/fromString s)))
21 |
22 | (defn update-if
23 | "Update m if k exists."
24 | [m k f]
25 | (if-let [v (get m k)]
26 | (assoc m k (f v))
27 | m))
28 |
29 | (defn dissoc-in
30 | "Dissociates an entry from a nested associative structure returning a new
31 | nested structure. keys is a sequence of keys. Any empty maps that result
32 | will not be present in the new structure."
33 | [m [k & ks :as keys]]
34 | (if ks
35 | (if-let [nextmap (get m k)]
36 | (let [newmap (dissoc-in nextmap ks)]
37 | (if (seq newmap)
38 | (assoc m k newmap)
39 | (dissoc m k)))
40 | m)
41 | (dissoc m k)))
42 |
43 | (defmacro doseq-indexed
44 | "loops over a set of values, binding index-sym to the 0-based index of each value"
45 | ([[val-sym values index-sym] & code]
46 | `(loop [vals# (seq ~values)
47 | ~index-sym (long 0)]
48 | (if vals#
49 | (let [~val-sym (first vals#)]
50 | ~@code
51 | (recur (next vals#) (inc ~index-sym)))
52 | nil))))
53 |
54 | (defn indexed [coll] (map-indexed vector coll))
55 |
56 | (defn set-timeout [f interval]
57 | (let [task (proxy [TimerTask] []
58 | (run [] (f)))
59 | timer (new Timer)]
60 | (.schedule timer task (long interval))
61 | timer))
62 |
63 | ;; http://yellerapp.com/posts/2014-12-11-14-race-condition-in-clojure-println.html
64 | (defn safe-println [& more]
65 | (.write *out* (str (clojure.string/join " " more) "\n")))
66 |
67 | (defn safe->int
68 | [s]
69 | (if (string? s)
70 | (Integer/parseInt s)
71 | s))
72 |
73 | (defn remove-nils
74 | [m]
75 | (reduce (fn [acc [k v]] (if v (assoc acc k v)
76 | acc))
77 | {} m))
78 |
79 | (defn deep-merge [& maps]
80 | (apply merge-with (fn [& args]
81 | (if (every? map? args)
82 | (apply deep-merge args)
83 | (last args)))
84 | maps))
85 |
86 | ;; TODO: add database-name
87 | (defn attach-db-spec!
88 | [config]
89 | (let [db-uri (java.net.URI. (or
90 | (System/getenv "DATABASE_URL")
91 | "postgres://localhost:5432/logseq"))
92 | user-info (.getUserInfo db-uri)
93 | [username password] (if user-info
94 | (clojure.string/split user-info #":"))
95 | custom {:username username
96 | :password password
97 | :server-name (.getHost db-uri)}]
98 | (update config :hikari-spec merge custom)))
99 |
100 | (defn asset-uri
101 | [branch-name path]
102 | (cond
103 | config/dev?
104 | path
105 |
106 | config/staging?
107 | (if branch-name
108 | (format "%s/%s%s" config/asset-uri branch-name path)
109 | (format "%s/%s%s" config/asset-uri "master" path))
110 |
111 | :else
112 | (str config/asset-uri path)))
113 |
114 | (defn ->sql-time
115 | [datetime]
116 | (^java.sql.Timestamp tc/to-sql-time datetime))
117 |
118 | (defn sql-now
119 | []
120 | (->sql-time (t/now)))
121 |
122 | (defn capitalize-all [s]
123 | (some->> (str/split s #" ")
124 | (map str/capitalize)
125 | (str/join " ")))
126 |
127 | ;; TODO: e.g. not working if s contains? "%20".
128 | (defn url-encoded?
129 | [s]
130 | (try
131 | (not= (codec/url-decode s) s)
132 | (catch Exception _e
133 | false)))
134 |
135 | (defmacro k-map
136 | "(let [a 1 b 2 c 3] (kv-map a b c))
137 | => {:a 1 :b 2 :c 3}"
138 | [& syms]
139 | `(zipmap (map keyword '~syms) (list ~@syms)))
140 |
141 | (defn rand-str
142 | [len]
143 | (->> (repeatedly #(char (+ (rand 26) 65)))
144 | (take len)
145 | (apply str)))
146 |
--------------------------------------------------------------------------------
/readme.org:
--------------------------------------------------------------------------------
1 | * Logseq
2 | Logseq is A privacy-first, open-source platform for knowledge sharing and management.
3 |
4 | ** *Warning*:
5 | The GitHub integration is deprecated, we're not going to accept any PRs.
6 |
7 | ** Website
8 | https://logseq.com
9 |
10 | ** Setup development environment
11 | If you're on Windows, use the [[#windows-setup][Windows setup]].
12 |
13 | *** 1. Requirements
14 |
15 | **** [[https://clojure.org/guides/getting_started][Java && Clojure]]
16 |
17 | **** [[https://www.postgresql.org/download/][PostgreSQL]]
18 |
19 | **** [[https://nodejs.org/en/][Node.js]]
20 |
21 | **** 2. Create a GitHub app
22 |
23 | Follow the guide at
24 | [[https://docs.github.com/en/free-pro-team@latest/developers/apps/creating-a-github-app]],
25 | where the user authorization "Callback URL" should be
26 | =http://localhost:3000/auth/github=.
27 |
28 | Remember to download the =private-key.pem= which will be used for the
29 | next step. Also take note of your =App ID=, =Client ID=, and your newly
30 | generated =Client Secret= for use in step 4.
31 |
32 | #+caption: Screenshot 2020-11-27 22-22-39 +0800
33 | [[https://user-images.githubusercontent.com/479169/100460276-e0bad100-3101-11eb-8fed-1f7c85824b62.png]]
34 |
35 | *Add contents permission*:
36 | [[https://user-images.githubusercontent.com/479169/100460271-def10d80-3101-11eb-91bb-f2339a52d4f8.png]]
37 |
38 | *** 3. Set up PostgreSQL
39 |
40 | Make sure you have PostgreSQL running. You can check if it's running
41 | with =pg_ctl -D /usr/local/var/postgres status= and use
42 | =pg_ctl -D /usr/local/var/postgres start= to start it up. You'll also
43 | need to make a Logseq DB in PostgreSQL. Do that with =createdb logseq=.
44 |
45 | *** 4. Add environment variables
46 | #+BEGIN_SRC sh
47 | export ENVIRONMENT="dev"
48 | export JWT_SECRET="xxx"
49 | export COOKIE_SECRET="xxx"
50 | export DATABASE_URL="postgresql://your-user:your-password@localhost:5432/logseq"
51 | export GITHUB_APP2_NAME="your github app name"
52 | export GITHUB_APP2_ID="your app id"
53 | export GITHUB_APP2_KEY="you client id"
54 | export GITHUB_APP2_SECRET="your secret"
55 | export GITHUB_REDIRECT_URI="http://localhost:3000/auth/github"
56 | # Replace your-code-directory with yours
57 | export GITHUB_APP_PEM="/your-code-directory/logseq/pem/logseq-test.2020-08-27.private-key.pem"
58 | export GOOGLE_APP_KEY="your key"
59 | export GOOGLE_APP_SECRET="your secret"
60 | export GOOGLE_REDIRECT_URI="http://localhost:3000/auth/google"
61 | export LOG_PATH="/tmp/logseq"
62 | export WEBSITE_URL="http://localhost:3000"
63 | export COOKIE_DOMAIN="localhost"
64 |
65 | export AWS_ACCESS_KEY_ID="your key id"
66 | export AWS_SECRET_ACCESS_KEY="your secret"
67 | export AWS_PK_PATH="/your/pk.pem"
68 | export AWS_KEY_PAIR_ID="your key pair id"
69 | export AWS_REGION="us-west-1"
70 | #+END_SRC
71 |
72 | *** 3. Start the clojure server
73 |
74 | **** Using in Emacs
75 | #+BEGIN_EXAMPLE
76 | 1. C-c M-j and select "clojure-cli"
77 | 2. input "(go)" in the clojure repl
78 | #+END_EXAMPLE
79 | (Note: to specify an alias from within emacs you can follow either option suggested [[https://github.com/clojure-emacs/cider/issues/2396][here]])
80 | **** Using in Cli
81 | #+BEGIN_EXAMPLE
82 | 1. clj -A:dev
83 | 2. input "(go)" in the clojure repl
84 | #+END_EXAMPLE
85 |
86 | **** Using in Calva (Visual Studio Code)
87 | #+BEGIN_EXAMPLE
88 | 1. Issue the command Start a REPL server and Connect: ctrl+alt+c ctrl+alt+j
89 | 2. Select clojure-cli
90 | 3. input "(go)" in the clojure repl
91 | #+END_EXAMPLE
92 |
93 | *** 4. Compile javascript
94 | #+BEGIN_SRC sh
95 | cd web
96 | yarn
97 | yarn watch
98 | open http://localhost:3000
99 | #+END_SRC
100 |
101 | ** Windows setup
102 |
103 | *** 1. Required software
104 | Install clojure through scoop-clojure: https://github.com/littleli/scoop-clojure. You can also install [[https://nodejs.org/en/][Node.js]], [[https://yarnpkg.com/][Yarn]] and [[https://www.postgresql.org/download/][PostgreSQL]] through scoop if you want to.
105 |
106 | *** 2. Setup PostgreSQL
107 | Make sure you have PostgreSQL running. You can check if it's running with ~pg_ctl status~ and use ~pg_ctl start~ to start it up.
108 | You'll also need to make a logseq DB in PostgreSQL. Do that with ~createdb logseq~.
109 |
110 | *** 3. Setup the server
111 | Download [[https://gist.github.com/samfundev/98088dd76f67085f114c75493261aa3d][this little script]] that sets up the environment variables and runs ~cmd-clj -A:dev~.
112 | The ~GITHUB_APP_PEM~ variable in the script needs to be set with the correct directory for your system.
113 | Run that script in the repo and enter ~(go)~ into the interpreter.
114 |
115 | *** 4. Setup the website
116 | Either run ~cmd-clojure -A:cljs watch app~ instead of ~yarn watch~ in the next step or modify web/package.json to use cmd-clojure instead of clojure in the scripts section.
117 |
118 | Open command prompt in the repo and do:
119 | #+BEGIN_SRC batch
120 | cd web
121 | yarn
122 | yarn watch
123 | #+END_SRC
124 | Wait for it to compile and open ~localhost:3000~.
125 |
126 | *** Notes
127 | 1. The clojure deps should be synced between the two files: ~project.clj~ and ~deps.edn~.
128 | We need the ~project.clj~ because it's used for dokku deployment.
129 | 2. To use github push, comment this line https://github.com/tiensonqin/logseq/blob/master/web/src/main/frontend/handler.cljs#L751
130 |
--------------------------------------------------------------------------------
/src/main/app/system.clj:
--------------------------------------------------------------------------------
1 | (ns app.system
2 | (:require [io.pedestal.http :as server]
3 | [reitit.ring :as ring]
4 | [reitit.http :as http]
5 | [reitit.coercion.spec]
6 | [reitit.swagger :as swagger]
7 | [reitit.swagger-ui :as swagger-ui]
8 | [reitit.http.coercion :as coercion]
9 | [reitit.dev.pretty :as pretty]
10 | [reitit.http.interceptors.parameters :as parameters]
11 | [reitit.http.interceptors.muuntaja :as muuntaja]
12 | [reitit.http.interceptors.exception :as exception]
13 | [reitit.http.interceptors.dev :as dev]
14 | [reitit.http.spec :as spec]
15 | [spec-tools.spell :as spell]
16 | [reitit.pedestal :as pedestal]
17 | [clojure.core.async :as a]
18 | [muuntaja.core :as m]
19 | [com.stuartsierra.component :as component]
20 | [app.components.http :as component-http]
21 | [app.components.hikari :as hikari]
22 | [app.components.services :as services]
23 | [app.routes :as routes]
24 | [app.config :as config]
25 | [io.pedestal.http.ring-middlewares :as ring-middlewares]
26 | [app.interceptors :as interceptors]
27 | [app.slack :as slack]))
28 |
29 | (def router
30 | (pedestal/routing-interceptor
31 | (http/router
32 | routes/routes
33 |
34 | {;:reitit.interceptor/transform dev/print-context-diffs ;; pretty context diffs
35 | ;;:validate spec/validate ;; enable spec validation for route data
36 | ;;:reitit.spec/wrap spell/closed ;; strict top-level validation
37 | ;; :exception pretty/exception
38 | :data {:coercion reitit.coercion.spec/coercion
39 | :muuntaja m/instance
40 | :interceptors [;; swagger feature
41 | swagger/swagger-feature
42 | ;; query-params & form-params
43 | (parameters/parameters-interceptor)
44 | ;; content-negotiation
45 | (muuntaja/format-negotiate-interceptor)
46 | ;; encoding response body
47 | (muuntaja/format-response-interceptor)
48 | ;; exception handling
49 | (exception/exception-interceptor (assoc
50 | exception/default-handlers
51 | :reitit.http.interceptors.exception/default
52 | (fn [^Exception e _]
53 | (slack/error e)
54 | {:status 500
55 | :body {:type "exception"
56 | :class (.getName (.getClass e))}})))
57 |
58 | ;; decoding request body
59 | (muuntaja/format-request-interceptor)
60 | ;; coercing response bodys
61 | (coercion/coerce-response-interceptor)
62 | ;; coercing request parameters
63 | (coercion/coerce-request-interceptor)]}
64 | :conflicts nil})
65 |
66 | ;; optional default ring handler (if no routes have matched)
67 | (ring/routes
68 | (swagger-ui/create-swagger-ui-handler
69 | {:path "/swagger"
70 | :config {:validatorUrl nil
71 | :operationsSorter "alpha"}})
72 | (ring/create-default-handler
73 | {:not-found routes/default-handler}))))
74 |
75 | (defn merge-interceptors-map
76 | [system-map interceptors]
77 | (update system-map :io.pedestal.http/interceptors
78 | (fn [old]
79 | (vec (concat interceptors old)))))
80 |
81 | (defn new-system
82 | [{:keys [env port hikari-spec] :as config}]
83 | (let [port (if (string? port)
84 | (Integer/parseInt port)
85 | port)
86 | service-map (-> {:env env
87 | ::server/type :jetty
88 | ::server/port port
89 | ::server/host "0.0.0.0"
90 | ::server/join? false
91 | ;; no pedestal routes
92 | ::server/routes []
93 | ;; allow serving the swagger-ui styles & scripts from self
94 | ;; ::server/secure-headers {:content-security-policy-settings
95 | ;; {:default-src "'self'"
96 | ;; :style-src "'self' 'unsafe-inline'"
97 | ;; :script-src "'self' 'unsafe-inline'"}}
98 | ::server/secure-headers {:content-security-policy-settings {:object-src "'none'"}}}
99 | (server/default-interceptors)
100 | ;; use the reitit router
101 | (pedestal/replace-last-interceptor router))
102 | service-map (let [current-dir (System/getProperty "user.dir")]
103 | (merge-interceptors-map
104 | service-map
105 | [ring-middlewares/cookies
106 | server/html-body
107 | interceptors/cookie-interceptor
108 | interceptors/etag-interceptor
109 | interceptors/gzip-interceptor
110 | (ring-middlewares/content-type)
111 | (ring-middlewares/file current-dir)]))
112 | ;; service-map (if config/dev? (server/dev-interceptors service-map) service-map)
113 | ]
114 | (println "Server is running on port " port "!")
115 | (component/system-map :service-map service-map
116 | :hikari (hikari/new-hikari-cp hikari-spec)
117 | :http
118 | (component/using
119 | (component-http/new-server)
120 | [:service-map])
121 | :services
122 | (component/using
123 | (services/new-services)
124 | [:hikari :service-map]))))
125 |
--------------------------------------------------------------------------------
/src/main/app/handler/auth.clj:
--------------------------------------------------------------------------------
1 | (ns app.handler.auth
2 | (:require [ring.util.response :as resp]
3 | [clj-social.core :as social]
4 | [clojure.set :as set]
5 | [clojure.string :as str])
6 | (:require [app.db.user :as u]
7 | [app.config :as config]
8 | [app.result :as r]
9 | [app.http :as h]
10 | [app.github :as github]
11 | [app.db.repo :as repo]
12 | [app.slack :as slack]
13 | [app.db.oauth-user :as oauth-user]
14 | [schema.utils :as utils]
15 | [app.util :as util]
16 | [clojure.spec.alpha :as s]
17 | [app.spec :as spec]))
18 |
19 | (defn- prepare-user-data
20 | [{:keys [name] :as oauth-user}]
21 | (let [m (select-keys oauth-user [:name :email :avatar])
22 | website (when (= "github" (:oauth_source oauth-user))
23 | (str "https://github.com/" name))]
24 | (utils/assoc-when m :website website)))
25 |
26 | (def ^:private user-appendix-length 6)
27 |
28 | (s/def :oauth/data
29 | (s/keys
30 | :req-un [:oauth/oauth_source :oauth/open_id]
31 | :opt-un [:oauth/name :oauth/email :oauth/avatar]))
32 |
33 | (defn get-from-oauth-user
34 | [{:keys [oauth_source open_id] :as oauth-data}]
35 | (when (and oauth_source open_id)
36 | (let [oauth-user (oauth-user/get-by-source-&-open-id oauth_source open_id)]
37 | (when oauth-user
38 | (oauth-user/update (:id oauth-user) oauth-data)
39 | oauth-user))))
40 |
41 | (defn- get-user-by-oauth-data
42 | [oauth-data]
43 | (spec/validate :oauth/data oauth-data)
44 | (if-let [oauth-user (get-from-oauth-user oauth-data)]
45 | (if-let [user (u/get (:user_id oauth-user))]
46 | (r/success user)
47 | (h/internal-server-error "Can't find user."))
48 | (let [user-data (prepare-user-data oauth-data)
49 | user-data
50 | (if (u/get-by-name (:name user-data))
51 | (let [rand-appendix (util/rand-str user-appendix-length)]
52 | (update user-data :name str rand-appendix))
53 | user-data)
54 | user (u/insert user-data)]
55 | (do (-> (assoc oauth-data :user_id (:id user))
56 | (oauth-user/insert))
57 | (r/success user)))))
58 |
59 | (defn- get-github-info
60 | [data]
61 | (try
62 | (let [{:keys [app-key app-secret redirect-uri]} (get-in config/config [:oauth :github-app])
63 | instance (social/make-social :github app-key app-secret redirect-uri
64 | :scope (str "user:email"))
65 | access-token (social/getAccessToken instance (:code data))
66 | info (social/getUserInfo instance access-token)]
67 | {:info info
68 | :access-token (.getAccessToken access-token)})
69 | (catch Exception e
70 | ;; TODO: figure out why code is not working here
71 | ;; Get github info error:
72 | ;; com.github.scribejava.core.exceptions.OAuthException: Response body is incorrect. Can't extract a 'access_token=([^&]+)' from this: 'error=bad_verification_code&error_description=The+code+passed+is+incorrect+or+expired.&error_uri=https%3A%2F%2Fdocs.github.com%2Fapps%2Fmanaging-oauth-apps%2Ftroubleshooting-oauth-app-access-token-request-errors%2F%23bad-verification-code'
73 | ;; (slack/error "Get github info error: " e)
74 | nil)))
75 |
76 | (defn- get-user-from-github-oauth [data]
77 | ;; User might reject the grant
78 | (when-let [m (get-github-info data)]
79 | (let [{:keys [info]} m]
80 | (r/check-r
81 | (when (:message info)
82 | (h/bad-request))
83 |
84 | (let [oauth-data
85 | (-> (select-keys info [:avatar_url :login :email :id])
86 | (set/rename-keys {:avatar_url :avatar
87 | :login :name
88 | :id :open_id})
89 | (assoc :open_id (str (:id info))
90 | :oauth_source :github))]
91 | (get-user-by-oauth-data oauth-data))))))
92 |
93 | (defn auth-github
94 | [{:keys [params] :as req}]
95 | (if (= (:error params) "access_denied")
96 | (resp/redirect config/website-uri)
97 | (r/check-r
98 | (when-not (:code params)
99 | (h/bad-request))
100 | (r/let-r [user (get-user-from-github-oauth params)
101 | user-id (:id user)
102 | installation-id (:installation_id params)
103 | token (when installation-id
104 | (:token (github/get-installation-access-token installation-id)))
105 | _ (when (and installation-id token)
106 | ;; update repos installation_id and default branch
107 | (let [repos (github/get-installation-repos token)]
108 | (when (seq repos)
109 | (repo/update-installation-id! installation-id repos)
110 | (doseq [url repos]
111 | (let [default-branch (github/get-repo-default-branch token url)
112 | branch (cond
113 | (= default-branch "master")
114 | default-branch
115 | (github/repo-empty? token url)
116 | "master"
117 | :else
118 | default-branch)]
119 | (repo/update-branch-by-user-id-and-url
120 | user-id url branch))))))
121 | redirect-uri config/website-uri]
122 | (if-let [user-id (:id user)]
123 | (let [cookies (u/generate-tokens user-id)]
124 | (-> (resp/redirect redirect-uri)
125 | (assoc :cookies cookies)))
126 | (slack/error "Github auth failed: " (util/k-map params user)))))))
127 |
128 | (defn- get-google-info
129 | [request-data]
130 | (try
131 | (let [{:keys [scope]} request-data
132 | {:keys [app-key app-secret redirect-uri]}
133 | (get-in config/config [:oauth :google])
134 | instance (social/make-social :google app-key app-secret
135 | redirect-uri
136 | :scope scope)
137 | access-token (social/getAccessToken instance (:code request-data))
138 | info (social/getUserInfo instance access-token)]
139 | {:info info
140 | :access-token (.getAccessToken access-token)})
141 | (catch Exception e
142 | (slack/error "Get google user info error: " e))))
143 |
144 | (defn- clip-user-name
145 | [user-name]
146 | (if (string? user-name)
147 | (-> (str/trim user-name)
148 | (str/replace #"\s" "")
149 | (str/lower-case))
150 | user-name))
151 |
152 | (defn- get-user-from-google-oauth
153 | [data]
154 | (let [{:keys [info] :as google-info} (get-google-info data)]
155 | (r/check-r
156 | (when-not info
157 | (slack/error "Google auth failed: " (util/k-map data google-info))
158 | (h/bad-request "OAuth Can't get user info."))
159 | (let [oauth-data (-> (select-keys info [:picture :given_name :email :sub])
160 | (set/rename-keys {:picture :avatar
161 | :given_name :name
162 | :sub :open_id})
163 | (update :name clip-user-name)
164 | (assoc :oauth_source :google))]
165 | (get-user-by-oauth-data oauth-data)))))
166 |
167 | (defn auth-google
168 | [{:keys [params] :as req}]
169 | (r/check-r
170 | (when-not (:code params)
171 | (h/redirect config/website-uri))
172 | (r/let-r [user (get-user-from-google-oauth params)
173 | redirect-uri config/website-uri
174 | user-id (:id user)
175 | cookies (u/generate-tokens user-id)]
176 | (h/redirect redirect-uri :cookies cookies))))
177 |
--------------------------------------------------------------------------------
/resources/js/worker.js:
--------------------------------------------------------------------------------
1 | importScripts(
2 | // Batched optimization
3 | "/js/lightning-fs.min.js?v=0.0.2.3",
4 | "https://cdn.jsdelivr.net/npm/isomorphic-git@1.7.4/index.umd.min.js",
5 | "https://cdn.jsdelivr.net/npm/isomorphic-git@1.7.4/http/web/index.umd.js",
6 | // Fixed a bug
7 | "/js/magic_portal.js"
8 | );
9 |
10 | const detect = () => {
11 | if (typeof window !== 'undefined' && !self.skipWaiting) {
12 | return 'window'
13 | } else if (typeof self !== 'undefined' && !self.skipWaiting) {
14 | return 'Worker'
15 | } else if (typeof self !== 'undefined' && self.skipWaiting) {
16 | return 'ServiceWorker'
17 | }
18 | };
19 |
20 | function basicAuth (username, token) {
21 | return "Basic " + btoa(username + ":" + token);
22 | }
23 |
24 | const fsName = 'logseq';
25 | const createFS = () => new LightningFS(fsName);
26 | let fs = createFS();
27 | let pfs = fs.promises;
28 |
29 | if (detect() === 'Worker') {
30 | const portal = new MagicPortal(self);
31 | portal.set('git', git);
32 | portal.set('fs', fs);
33 | portal.set('pfs', pfs);
34 | portal.set('gitHttp', GitHttp);
35 | portal.set('workerThread', {
36 | setConfig: function (dir, path, value) {
37 | return git.setConfig ({
38 | fs,
39 | dir,
40 | path,
41 | value
42 | });
43 | },
44 | clone: function (dir, url, corsProxy, depth, branch, username, token) {
45 | return git.clone ({
46 | fs,
47 | dir,
48 | http: GitHttp,
49 | url,
50 | corsProxy,
51 | ref: branch,
52 | singleBranch: true,
53 | depth,
54 | headers: {
55 | "Authorization": basicAuth(username, token)
56 | }
57 | });
58 | },
59 | fetch: function (dir, url, corsProxy, depth, branch, username, token) {
60 | return git.fetch ({
61 | fs,
62 | dir,
63 | http: GitHttp,
64 | url,
65 | corsProxy,
66 | ref: branch,
67 | singleBranch: true,
68 | depth,
69 | headers: {
70 | "Authorization": basicAuth(username, token)
71 | }
72 | });
73 | },
74 | pull: function (dir, corsProxy, branch, username, token) {
75 | return git.pull ({
76 | fs,
77 | dir,
78 | http: GitHttp,
79 | corsProxy,
80 | ref: branch,
81 | singleBranch: true,
82 | // fast: true,
83 | headers: {
84 | "Authorization": basicAuth(username, token)
85 | }
86 | });
87 | },
88 | push: function (dir, corsProxy, branch, force, username, token) {
89 | return git.push ({
90 | fs,
91 | dir,
92 | http: GitHttp,
93 | ref: branch,
94 | corsProxy,
95 | remote: "origin",
96 | force,
97 | headers: {
98 | "Authorization": basicAuth(username, token)
99 | }
100 | });
101 | },
102 | merge: function (dir, branch) {
103 | return git.merge ({
104 | fs,
105 | dir,
106 | ours: branch,
107 | theirs: "remotes/origin/" + branch,
108 | // fastForwardOnly: true
109 | });
110 | },
111 | checkout: function (dir, branch) {
112 | return git.checkout ({
113 | fs,
114 | dir,
115 | ref: branch,
116 | });
117 | },
118 | log: function (dir, branch, depth) {
119 | return git.log ({
120 | fs,
121 | dir,
122 | ref: branch,
123 | depth,
124 | singleBranch: true
125 | })
126 | },
127 | add: function (dir, file) {
128 | return git.add ({
129 | fs,
130 | dir,
131 | filepath: file
132 | });
133 | },
134 | remove: function (dir, file) {
135 | return git.remove ({
136 | fs,
137 | dir,
138 | filepath: file
139 | });
140 | },
141 | commit: function (dir, message, name, email, parent) {
142 | if (parent) {
143 | return git.commit ({
144 | fs,
145 | dir,
146 | message,
147 | author: {name: name,
148 | email: email},
149 | parent: parent
150 | });
151 | } else {
152 | return git.commit ({
153 | fs,
154 | dir,
155 | message,
156 | author: {name: name,
157 | email: email}
158 | });
159 | }
160 | },
161 | readCommit: function (dir, oid) {
162 | return git.readCommit ({
163 | fs,
164 | dir,
165 | oid
166 | });
167 | },
168 | readBlob: function (dir, oid, path) {
169 | return git.readBlob ({
170 | fs,
171 | dir,
172 | oid,
173 | path
174 | });
175 | },
176 | writeRef: function (dir, branch, oid) {
177 | return git.writeRef ({
178 | fs,
179 | dir,
180 | ref: "refs/heads/" + branch,
181 | value: oid,
182 | force: true
183 | });
184 | },
185 | resolveRef: function (dir, ref) {
186 | return git.resolveRef ({
187 | fs,
188 | dir,
189 | ref
190 | });
191 | },
192 | listFiles: function (dir, branch) {
193 | return git.listFiles ({
194 | fs,
195 | dir,
196 | ref: branch
197 | });
198 | },
199 | rimraf: async function (path) {
200 | // try {
201 | // // First assume path is itself a file
202 | // await pfs.unlink(path)
203 | // // if that worked we're done
204 | // return
205 | // } catch (err) {
206 | // // Otherwise, path must be a directory
207 | // if (err.code !== 'EISDIR') throw err
208 | // }
209 | // Knowing path is a directory,
210 | // first, assume everything inside path is a file.
211 | let files = await pfs.readdir(path);
212 | for (let file of files) {
213 | let child = path + '/' + file
214 | try {
215 | await pfs.unlink(child)
216 | } catch (err) {
217 | if (err.code !== 'EISDIR') throw err
218 | }
219 | }
220 | // Assume what's left are directories and recurse.
221 | let dirs = await pfs.readdir(path)
222 | for (let dir of dirs) {
223 | let child = path + '/' + dir
224 | await rimraf(child, pfs)
225 | }
226 | // Finally, delete the empty directory
227 | await pfs.rmdir(path)
228 | },
229 | getFileStateChanges: async function (commitHash1, commitHash2, dir) {
230 | return git.walk({
231 | fs,
232 | dir,
233 | trees: [git.TREE({ ref: commitHash1 }), git.TREE({ ref: commitHash2 })],
234 | map: async function(filepath, [A, B]) {
235 | var type = 'equal';
236 | if (A === null) {
237 | type = "add";
238 | }
239 |
240 | if (B === null) {
241 | type = "remove";
242 | }
243 |
244 | // ignore directories
245 | if (filepath === '.') {
246 | return
247 | }
248 | if ((A !== null && (await A.type()) === 'tree')
249 | ||
250 | (B !== null && (await B.type()) === 'tree')) {
251 | return
252 | }
253 |
254 | // generate ids
255 | const Aoid = A !== null && await A.oid();
256 | const Boid = B !== null && await B.oid();
257 |
258 | if (type === "equal") {
259 | // determine modification type
260 | if (Aoid !== Boid) {
261 | type = 'modify'
262 | }
263 | if (Aoid === undefined) {
264 | type = 'add'
265 | }
266 | if (Boid === undefined) {
267 | type = 'remove'
268 | }
269 | }
270 |
271 | if (Aoid === undefined && Boid === undefined) {
272 | console.log('Something weird happened:')
273 | console.log(A)
274 | console.log(B)
275 | }
276 |
277 | return {
278 | path: `/${filepath}`,
279 | type: type,
280 | }
281 | },
282 | })
283 | },
284 | statusMatrix: async function (dir) {
285 | return git.statusMatrix({ fs, dir });
286 | },
287 | statusMatrixChanged: async function (dir) {
288 | return (await git.statusMatrix({ fs, dir }))
289 | .filter(([_, head, workDir, stage]) => !(head == 1 && workDir == 1 && stage == 1));
290 | },
291 | getChangedFiles: async function (dir) {
292 | try {
293 | const FILE = 0, HEAD = 1, WORKDIR = 2;
294 |
295 | let filenames = (await git.statusMatrix({ fs, dir }))
296 | .filter(row => row[HEAD] !== row[WORKDIR])
297 | .map(row => row[FILE]);
298 |
299 | return filenames;
300 | } catch (err) {
301 | console.error(err);
302 | return [];
303 | }
304 | }
305 | });
306 | // self.addEventListener("message", ({ data }) => console.log(data));
307 | }
308 |
--------------------------------------------------------------------------------
/src/main/app/github.clj:
--------------------------------------------------------------------------------
1 | (ns app.github
2 | (:require [app.config :as config]
3 | [buddy.sign.jwt :as jwt]
4 | [clj-http.client :as http]
5 | [buddy.core.keys :as ks]
6 | [clj-social.core :as social]
7 | [cheshire.core :as json]
8 | [app.slack :as slack]
9 | [clojure.string :as string]
10 | [app.util :as util]
11 | [app.db.oauth-user :as oauth-user]
12 | [clj-time.core :as t])
13 | (:import [java.util Date]))
14 |
15 | ;; There're several problems which need to be resolved before released:
16 | ;; 1. What if the user suspended the installation, I suspect the token will not working anymore
17 | ;; A: the app prompt the user to install the app
18 | ;; 2. What if the user uninstalled the app
19 | ;; A: same answer as above :)
20 | ;; 3. Is one refresh token corresponds to all the tokens for the user and all the organization repos?
21 | ;; A: TBD
22 | ;; 4. Do user and organizations have different installation ids
23 | ;; A: Yes
24 |
25 | (def api-url "https://api.github.com")
26 | (def app-id (get-in config/config [:oauth :github-app :app-id]))
27 | (def app-key (get-in config/config [:oauth :github-app :app-key]))
28 | (def app-secret (get-in config/config [:oauth :github-app :app-secret]))
29 | (def app-name (if config/dev?
30 | (get-in config/config [:oauth :github-app :app-name])
31 | "logseq"))
32 | (def redirect-uri (get-in config/config [:oauth :github-app :redirect-uri]))
33 | (def private-key-pem (get-in config/config [:oauth :github-app :app-private-key-pem]))
34 | (def app-install-uri (str "https://github.com/apps/" app-name "/installations/new"))
35 |
36 | (defn jwt-sign
37 | []
38 | (let [now (Date.)
39 | now-30s (Date. ^long (-> now (.getTime) (- (* 1000 30))))
40 | now+8m (Date. ^long (-> now (.getTime) (+ (* 1000 60 8))))]
41 | (jwt/sign {:iss app-id
42 | :iat now-30s
43 | :exp now+8m}
44 | (ks/private-key private-key-pem)
45 | {:alg :rs256})))
46 |
47 | ;; Get app information
48 | (defn app-auth
49 | []
50 | (http/get (str api-url "/app")
51 | {:headers {"authorization" (str "Bearer " (jwt-sign))
52 | "accept" "application/vnd.github.machine-man-preview+json"}}))
53 |
54 | (defn exchange-token
55 | [code]
56 | (-> (http/post "https://github.com/login/oauth/access_token"
57 | {:headers {"accept" "application/json"}
58 | :query-params {:client_id app-key
59 | :client_secret app-secret
60 | :code code
61 | :redirect_uri redirect-uri}})
62 | :body
63 | (json/parse-string true)))
64 |
65 | (defn get-user
66 | [access-token]
67 | (-> (http/get (str api-url "/user")
68 | {:headers {"authorization" (str "token " access-token)
69 | "accept" "application/json"}})
70 | :body
71 | (json/parse-string true)))
72 |
73 | ;; /installation/repositories
74 | (defn get-installation-repos
75 | [installation-token]
76 | (->>
77 | (->
78 | (http/get (str api-url "/installation/repositories")
79 | {:headers {"authorization" (str "Bearer " installation-token)
80 | "accept" "application/vnd.github.machine-man-preview+json"}})
81 | :body
82 | (json/parse-string true)
83 | :repositories)
84 | (map :html_url)
85 | (distinct)))
86 |
87 | ;; /app/installations/{installation_id}/access_tokens
88 | ;; https://docs.github.com/en/rest/reference/apps#create-an-installation-access-token-for-an-app
89 | ;; curl \
90 | ;; -X POST \
91 | ;; -H "Accept: application/vnd.github.machine-man-preview+json" \
92 | ;; TODO: better error handler, e.g. github api problem, invalid installation id due to revoking
93 | ;; TODO: refresh token
94 | (defn get-installation-access-token
95 | [installation-id]
96 | (try
97 | (let [result (:body (http/post (str api-url "/app/installations/" installation-id "/access_tokens")
98 | {:headers {"authorization" (str "Bearer " (jwt-sign))
99 | "accept" "application/vnd.github.machine-man-preview+json"}}))]
100 | (json/parse-string result true))
101 | (catch Exception e
102 | ;; (slack/error (str "Get installation access token for " installation-id) e)
103 | nil)))
104 |
105 | (defn- get-git-owner-and-repo
106 | [repo-url]
107 | (take-last 2 (string/split repo-url #"/")))
108 |
109 | (defn get-repo-permission
110 | [username installation-id repo-url]
111 | (when-let [token (:token (get-installation-access-token installation-id))]
112 | (let [[owner repo-name] (get-git-owner-and-repo repo-url)
113 | url (str api-url
114 | (format "/repos/%s/%s/collaborators/%s/permission"
115 | owner
116 | repo-name
117 | username))]
118 | (->
119 | (http/get url
120 | {:headers {"authorization" (str "Bearer " token)
121 | "accept" "application/vnd.github.machine-man-preview+json"}})
122 | :body
123 | (json/parse-string true)))))
124 |
125 | (defn check-permission?
126 | [owner installation-id repo]
127 | (if-let [github-name (-> (oauth-user/get-by-user-id-from-github (:id owner)) :name)]
128 | (try
129 | (when (contains? #{"admin" "write"}
130 | (:permission (get-repo-permission github-name installation-id repo)))
131 | true)
132 | (catch Exception e
133 | (slack/error (format "Someone(without the access) wants to visit: %s"
134 | (util/k-map github-name installation-id repo))
135 | e)
136 | false))
137 | (do (slack/error (format "Can't find github name. Args: %s"
138 | (util/k-map owner installation-id repo)))
139 | false)))
140 |
141 | (defn get-repo-installation-id
142 | [user repo]
143 | (try
144 | (let [[owner repo-name] (take-last 2 (string/split repo #"\/"))]
145 | (when (and owner repo-name)
146 | (let [{:keys [id app_id] :as result}
147 | (->
148 | (http/get (str api-url (format "/repos/%s/%s/installation" owner repo-name))
149 | {:headers {"authorization" (str "Bearer " (jwt-sign))
150 | "accept" "application/vnd.github.machine-man-preview+json"}})
151 | :body
152 | (json/parse-string true))
153 | id (and (= (str app-id) (str app_id))
154 | (str id))]
155 | (when id
156 | (when (check-permission? user id repo)
157 | id)))))
158 | (catch Exception e
159 | nil)))
160 |
161 | ;; GET /repos/:owner/:repo/contents
162 | (defn repo-empty?
163 | [token repo-url]
164 | (try
165 | (let [[owner repo-name] (get-git-owner-and-repo repo-url)
166 | url (str api-url
167 | (format "/repos/%s/%s/contents"
168 | owner
169 | repo-name))]
170 | (http/get url
171 | {:headers {"authorization" (str "Bearer " token)
172 | "accept" "application/vnd.github.machine-man-preview+json"}})
173 | false)
174 | (catch Exception e
175 | (let [message (:message (json/parse-string (:body (ex-data e)) true))]
176 | (= message "This repository is empty.")))))
177 |
178 | (defn get-repo-default-branch
179 | [token repo-url]
180 | (let [[owner repo-name] (get-git-owner-and-repo repo-url)
181 | url (str api-url
182 | (format "/repos/%s/%s"
183 | owner
184 | repo-name))]
185 | (try
186 | (-> (http/get url
187 | {:headers {"authorization" (str "Bearer " token)
188 | "accept" "application/vnd.github.machine-man-preview+json"}})
189 | :body
190 | (json/parse-string true)
191 | :default_branch)
192 | (catch Exception e
193 | (slack/debug {:url url}
194 | e)
195 | "master"))))
196 |
197 | (defonce latest-release (atom nil))
198 | (defonce last-fetched-at (atom nil))
199 |
200 | ;; GET /repos/:owner/:repo/releases/latest
201 | (defn get-logseq-latest-release
202 | []
203 | (if (and @latest-release
204 | @last-fetched-at
205 | (t/before? (t/now) (t/plus @last-fetched-at (t/minutes 1))))
206 | @latest-release
207 | (try
208 | (let [result (-> (http/get (str api-url "/repos/logseq/logseq/releases/latest")
209 | {:headers {"accept" "application/vnd.github.machine-man-preview+json"}})
210 | :body
211 | (json/parse-string true))
212 | release (->> (map :browser_download_url (:assets result))
213 | (filter #(not (string/ends-with? % ".zip"))))
214 | release (->> (for [asset release]
215 | (let [k (cond
216 | (string/includes? asset "darwin-arm64")
217 | "Mac-M1"
218 | (string/includes? asset "darwin-x64")
219 | "Mac"
220 | (string/includes? asset "linux-x64")
221 | "Linux"
222 | (string/includes? asset "win-x64")
223 | "Windows")]
224 | [k asset]))
225 | (into {}))]
226 | (when (seq release)
227 | (reset! latest-release release)
228 | (reset! last-fetched-at (t/now)))
229 | release)
230 | (catch Exception e
231 | nil))))
232 |
233 | (comment
234 | (def installation-id "11503160")
235 | )
236 |
--------------------------------------------------------------------------------
/src/main/app/routes.clj:
--------------------------------------------------------------------------------
1 | (ns app.routes
2 | (:require [reitit.swagger :as swagger]
3 | [clj-social.core :as social]
4 | [app.config :as config]
5 | [app.util :as util]
6 | [app.db.user :as u]
7 | [app.db.repo :as repo]
8 | [app.db.project :as project]
9 | [app.db.page :as page]
10 | [app.handler.page :as page-handler]
11 | [app.handler.user :as user-handler]
12 | [ring.util.response :as resp]
13 | [ring.util.codec :as codec]
14 | [app.views.home :as home]
15 | [app.interceptors :as interceptors]
16 | [io.pedestal.http.ring-middlewares :as ring-middlewares]
17 | [ring.util.response :as response]
18 | [app.s3 :as s3]
19 | [app.aws :as aws]
20 | [clojure.java.io :as io]
21 | [clojure.string :as string]
22 | [lambdaisland.uri :as uri]
23 | [app.slack :as slack]
24 | [app.handler.rss :as rss]
25 | [app.reserved-routes :as reserved]
26 | [app.github :as github]
27 | [app.result :as r]
28 | [app.handler.project :as h-project]
29 | [app.handler.auth :as h-auth]
30 | [app.http :as h]
31 | [app.db.oauth-user :as oauth-user]
32 | [schema.utils :as utils]
33 | [app.cookie :as cookie]))
34 |
35 | ;; TODO: spec validate, authorization (owner?)
36 |
37 | (defn default-handler
38 | [{:keys [app-context query-params] :as req}]
39 | (let [github-authed? (-> (get-in app-context [:user :id])
40 | (oauth-user/get-github-auth)
41 | boolean)
42 | user (some-> (:user app-context)
43 | (assoc :github-authed? github-authed?))
44 | git-branch-name (:b query-params)
45 | spa? (or (:spa query-params)
46 | (get-in req [:cookies "spa" :value])
47 | user)
48 | body (home/home user spa? git-branch-name)]
49 | (cond->
50 | {:status 200
51 | :body body
52 | :headers {"Content-Type" "text/html"
53 | "X-Frame-Options" "SAMEORIGIN"}}
54 | (or spa? user)
55 | (assoc :cookies cookie/spa))))
56 |
57 | (def project-check
58 | {:name ::project-check
59 | :enter (fn [{:keys [request response] :as ctx}]
60 | (let [{:keys [path-params]} request
61 | project (:project path-params)
62 | resp (if (project/exists? project)
63 | response
64 | (default-handler request))]
65 | (assoc ctx :response resp)))})
66 |
67 | (defn ->tags
68 | [tags]
69 | (vec (distinct (apply concat (map #(string/split % #",\s?") tags)))))
70 |
71 | (def routes
72 | [["/check.txt"
73 | {:get {:no-doc true
74 | :handler (fn [_]
75 | {:status 200
76 | :body "dokku-check"})}}]
77 | ["/swagger.json"
78 | {:get {:no-doc true
79 | :swagger {:info {:title "logseq api"
80 | :description "with pedestal & reitit-http"}}
81 | :handler (swagger/create-swagger-handler)}}]
82 |
83 | ["/"
84 | {:get {:no-doc true
85 | :handler default-handler}}]
86 |
87 | ["/logout"
88 | {:get {:no-doc true
89 | :handler interceptors/logout}}]
90 | ["/login"
91 | {:swagger {:tags ["Login"]}}
92 |
93 | ["/github"
94 | {:get {:summary "Login with github"
95 | :handler
96 | (fn [req]
97 | (let [{:keys [app-key app-secret redirect-uri]} (get-in config/config [:oauth :github-app])
98 | social (social/make-social :github app-key app-secret
99 | redirect-uri
100 | :scope "user:email")
101 | url (social/getAuthorizationUrl social)]
102 | (resp/redirect url)))}}]
103 | ;; ["/google"
104 | ;; {:get {:summary "Login with google"
105 | ;; :handler
106 | ;; (fn [req]
107 | ;; (let [{:keys [app-key app-secret redirect-uri]} (get-in config/config [:oauth :google])
108 | ;; social (social/make-social :google app-key app-secret
109 | ;; redirect-uri
110 | ;; :scope "email profile openid")
111 | ;; url (social/getAuthorizationUrl social)]
112 | ;; (resp/redirect url)))}}]
113 | ]
114 | ["/auth"
115 | {:swagger {:tags ["Authenticate"]}}
116 |
117 | ["/github"
118 | {:get {:summary "Authenticate with github"
119 | :handler h-auth/auth-github
120 | }}]
121 | ;; ["/google"
122 | ;; {:get {:summary "Authenticate with google"
123 | ;; :handler (fn [req]
124 | ;; (-> (h-auth/auth-google req)
125 | ;; (h/result->http-map)))}}]
126 | ]
127 |
128 | ["/static/" {:swagger {:no-doc true}
129 | :interceptors [(when-not config/dev?
130 | (interceptors/cache-control 315360000))]}
131 | ["*path" {:get {:no-doc true
132 | :handler (fn [req]
133 | (let [{{:keys [path]} :path-params} req]
134 | (response/resource-response (str "static/" path))))}}]]
135 |
136 | ["/js/" {:swagger {:no-doc true}
137 | :interceptors [(when-not config/dev?
138 | (interceptors/cache-control 315360000))]}
139 | ["*path" {:get {:no-doc true
140 | :handler (fn [req]
141 | (let [{{:keys [path]} :path-params} req]
142 | (response/resource-response (str "js/" path))))}}]]
143 |
144 | ["/api/v1"
145 | ["/account"
146 | {:delete {:handler (fn [req]
147 | (let [resp (user-handler/delete! req)]
148 | (prn {:req req
149 | :resp resp})
150 | (h/result->http-map resp)))}}]
151 |
152 | ["/refresh_github_token"
153 | {:post {:handler
154 | (fn [{:keys [app-context body-params] :as req}]
155 | (if-not (:uid app-context)
156 | {:status 401
157 | :body {:message "Unauthorized."}}
158 | (let [user (:user app-context)
159 | {:keys [repos]} body-params
160 | repos (->> (map :url repos)
161 | (remove nil?))]
162 | (cond
163 | (seq repos)
164 | (let [installation-ids (->> (doall
165 | (map (fn [repo]
166 | (when-let [id (github/get-repo-installation-id user repo)]
167 | (repo/update-repo-installation-id! repo id)
168 | id)) repos))
169 | (remove nil?))]
170 | (if (seq installation-ids)
171 | (do
172 | (interceptors/clear-user-cache (:id user))
173 | {:status 200
174 | :body (mapv
175 | (fn [installation-id]
176 | (let [{:keys [token expires_at]} (github/get-installation-access-token installation-id)]
177 | {:installation_id installation-id
178 | :token token
179 | :expires_at expires_at}))
180 | installation-ids)})
181 | (do
182 | (repo/clear-user-repos! (:id user))
183 | {:status 200
184 | :body []})))
185 | :else
186 | {:status 400
187 | :body {:message "Invalid installation-ids"}}))))}}]
188 |
189 | ["/email"
190 | {:post {:summary "Update email"
191 | :handler
192 | (fn [{:keys [app-context body-params] :as req}]
193 | (let [email (:email body-params)]
194 | (if (not (string/blank? email))
195 | (let [user (:user app-context)
196 | [ok-bad result] (u/update-email (:id user) email)]
197 | (interceptors/clear-user-cache (:id user))
198 | (if (= :ok ok-bad)
199 | {:status 200
200 | :body {:message "Update successfully"}}
201 | {:status 400
202 | :body {:message "Email address already exists!"}}))
203 | {:status 400
204 | :body {:message "email is required!"}})))}}]
205 |
206 | ["/cors_proxy"
207 | {:post {:summary "Update cors_proxy"
208 | :handler
209 | (fn [{:keys [app-context body-params] :as req}]
210 | (if-let [user (:user app-context)]
211 | (if-let [cors-proxy (:cors-proxy body-params)]
212 | (let [_result (u/update (:id user) {:cors_proxy cors-proxy})]
213 | (interceptors/clear-user-cache (:id user))
214 | {:status 200
215 | :body {:message "Update successfully"}})
216 | {:status 400
217 | :body {:message "cors_proxy is required!"}})
218 | {:status 400
219 | :body {:message "Please login first."}}))}}]
220 |
221 | ["/set_preferred_format"
222 | {:post {:summary "Update preferred format"
223 | :handler
224 | (fn [{:keys [app-context body-params] :as req}]
225 | (let [preferred_format (string/lower-case (:preferred_format body-params))]
226 | (if (contains? #{"org" "markdown"} preferred_format)
227 | (let [user (:user app-context)
228 | result (u/update (:id user) {:preferred_format preferred_format})]
229 | (interceptors/clear-user-cache (:id user))
230 | {:status 200
231 | :body {:message "Update successfully"}})
232 | {:status 400
233 | :body {:message "Only org and markdown are supported!"}})))}}]
234 | ["/set_preferred_workflow"
235 | {:post {:summary "Update preferred workflow"
236 | :handler
237 | (fn [{:keys [app-context body-params] :as req}]
238 | (let [preferred_workflow (string/lower-case (:preferred_workflow body-params))]
239 | (if (contains? #{"todo" "now"} preferred_workflow)
240 | (let [user (:user app-context)
241 | result (u/update (:id user) {:preferred_workflow preferred_workflow})]
242 | (interceptors/clear-user-cache (:id user))
243 | {:status 200
244 | :body {:message "Update successfully"}})
245 | {:status 400
246 | :body {:message "Only todo and now are supported!"}})))}}]
247 | ["/repos"
248 | {:post {:summary "Add a repo"
249 | :handler
250 | (fn [{:keys [app-context body-params] :as req}]
251 | (let [user (:user app-context)]
252 | (if (:id user)
253 | (let [repo (:url body-params)]
254 | (if-let [installation-id (github/get-repo-installation-id user repo)]
255 | (let [result (repo/insert {:user_id (:id user)
256 | :url (:url body-params)
257 | :branch (:branch body-params)
258 | :installation_id installation-id})]
259 | {:status 201
260 | :body result})
261 | ;; install app
262 | (let [result (repo/insert {:user_id (:id user)
263 | :url (:url body-params)
264 | :branch (:branch body-params)})]
265 | {:status 201
266 | :body result})))
267 | {:status 401
268 | :body "Invalid request"})))}}]
269 |
270 | ["/repos/:id"
271 | {:post {:summary "Update a repo's url"
272 | :handler
273 | (fn [{:keys [app-context params body-params] :as req}]
274 | (let [user (:user app-context)
275 | args (select-keys body-params [:url :branch])
276 | args (util/remove-nils args)
277 | result (when (seq args)
278 | (repo/update (:id params) args))]
279 | (interceptors/clear-user-cache (:id user))
280 |
281 | {:status 200
282 | :body result}))}
283 | :delete {:summary "Delete a repo"
284 | :handler
285 | (fn [{:keys [app-context path-params] :as req}]
286 | (let [user (:user app-context)
287 | id (util/->uuid (:id path-params))]
288 | (if (and user id (repo/belongs-to? id (:id user)))
289 | (do
290 | (repo/delete id)
291 | (interceptors/clear-user-cache (:id user))
292 | {:status 200
293 | :body {:result true}})
294 | {:status 401
295 | :body "Invalid request"})))}}]
296 |
297 | ["/projects"
298 | {:post {:summary "Add a project"
299 | :handler (fn [req]
300 | (-> (h-project/create-project req)
301 | (h/result->http-map)))}}]
302 | ["/projects/:name"
303 | [""
304 | {:post {:summary "Update a project's settings"
305 | :handler (fn [req]
306 | (-> (h-project/update-project req)
307 | (h/result->http-map)))}
308 |
309 | :delete {:summary "delete a project"
310 | :handler (fn [req]
311 | (-> (h-project/delete-project req)
312 | (h/result->http-map)))}}]
313 | ["/pages"
314 | {:get {:summary "Get pages that belong the project."
315 | :handler (fn [req]
316 | (-> (page-handler/get-page-list req)
317 | (h/result->http-map)))}}]]
318 |
319 | ["/pages"
320 | {:post {:summary "Add a new page"
321 | :handler (fn [req]
322 | (-> (page-handler/create-page req)
323 | (h/result->old-http-map)))
324 | }}]
325 |
326 | ["/:project/:permalink"
327 | {:delete {:summary "Delete a page"
328 | :handler (fn [req]
329 | (-> (page-handler/delete-page req)
330 | (h/result->http-map)))}}]
331 |
332 | ;; TODO: limit usage
333 | ["/presigned_url"
334 | {:post {:summary "Request a aws s3 presigned url."
335 | :handler
336 | (fn [{:keys [app-context body-params params] :as req}]
337 | (let [{:keys [filename mime-type]} body-params
338 | {:keys [access-key-id secret-access-key bucket]} (:aws config/config)
339 | user (:user app-context)
340 | presigned-url (s3/generate-presigned-url
341 | access-key-id
342 | secret-access-key
343 | bucket
344 | (str "/" (:id user) (util/uuid) filename))]
345 | (if presigned-url
346 | (let [result {:presigned-url presigned-url
347 | :s3-object-key (-> (:path (uri/uri presigned-url))
348 | (string/replace (format "/%s/" bucket) ""))}]
349 | {:status 201
350 | :body result})
351 | {:status 400
352 | :body {:message "Something wrong"}})))}}]
353 |
354 | ;; TODO: track user images usage
355 | ["/signed_url"
356 | {:post {:summary "Request a aws cloudfront presigned url."
357 | :handler
358 | (fn [{:keys [app-context body-params params] :as req}]
359 | (let [{:keys [s3-object-key]} body-params
360 | signed-url (aws/get-signed-url s3-object-key
361 | ;; 100 years, oh my!
362 | (* 60 24 365 100))]
363 | (if signed-url
364 | {:status 201
365 | :body {:signed-url signed-url}}
366 | {:status 400
367 | :body {:message "Something wrong"}})))}}]]
368 |
369 | ["/:project" {:swagger {:no-doc true}
370 | :interceptors [project-check
371 | (when-not config/dev?
372 | (interceptors/cache-control 315360000))]}
373 | [""
374 | {:get {:handler
375 | (fn [{:keys [app-context path-params query-params] :as req}]
376 | (let [{:keys [project]} path-params
377 | git-branch-name (:b query-params)]
378 | (if-let [project-id (project/get-id-by-name project)]
379 | (let [html (page-handler/get-project-index-page project-id git-branch-name)]
380 | {:status 200
381 | :headers {"Content-Type" "text/html"}
382 | :body html})
383 | {:status 404
384 | :body "Not found"})))}}]
385 | ["/latest.rss"
386 | {:get {:handler
387 | (fn [{:keys [app-context path-params] :as req}]
388 | (let [{:keys [project]} path-params]
389 | (if-let [project-id (project/get-id-by-name project)]
390 | (rss/rss-page project project-id)
391 | {:status 404
392 | :body "Not found"})))}}]
393 | ["/tag/:tag"
394 | {:get {:handler
395 | (fn [{:keys [app-context path-params query-params] :as req}]
396 | (let [{:keys [project tag]} path-params
397 | git-branch-name (:b query-params)]
398 | (if-let [project-id (project/get-id-by-name project)]
399 | (let [html (page-handler/get-project-tag-page project-id tag git-branch-name)]
400 | {:status 200
401 | :headers {"Content-Type" "text/html"}
402 | :body html})
403 | {:status 404
404 | :body "Not found"})))}}]
405 | ["/:permalink"
406 | {:get {:handler
407 | (fn [{:keys [app-context path-params query-params] :as req}]
408 | (let [{:keys [project permalink]} path-params
409 | permalink (codec/url-encode permalink)
410 | git-branch-name (:b query-params)]
411 | (if-let [project-id (project/get-id-by-name project)]
412 | (if-let [page (page/get-by-project-id-and-permalink project-id permalink)]
413 | (let [html (page-handler/get-page project-id project permalink page git-branch-name)]
414 | {:status 200
415 | :headers {"Content-Type" "text/html"}
416 | :body html})
417 | {:status 404
418 | :body "Page not found"})
419 | {:status 404
420 | :body "Not found"})))}}]]])
421 |
--------------------------------------------------------------------------------
/src/main/app/handler/page.clj:
--------------------------------------------------------------------------------
1 | (ns app.handler.page
2 | (:require [hiccup.page :as html]
3 | [hiccup.util :as hiccup-util]
4 | [hickory.core :as hickory]
5 | [clj-time.core :as t]
6 | [clj-time.coerce :as tc]
7 | [clj-time.format :as tf]
8 | [clojure.string :as string]
9 | [clojure.walk :as walk]
10 | [clojure.core.cache :as cache]
11 | [ring.util.codec :as codec])
12 | (:require [app.db.page :as page]
13 | [app.db.project :as project]
14 | [app.config :as config]
15 | [app.util :as util]
16 | [app.result :as r]
17 | [app.interceptors :as interceptors]
18 | [app.http :as h]
19 | [app.handler.utils :as hu]
20 | [app.db.repo :as repo]))
21 |
22 | (defn sql-date->str
23 | [sql-date]
24 | (let [date-time (tc/to-date-time sql-date)]
25 | (tf/unparse (tf/formatter "MMM dd, yyyy") date-time)))
26 |
27 | (defn full-sql-date->str
28 | [sql-date]
29 | (let [date-time (tc/to-date-time sql-date)]
30 | (tf/unparse (tf/formatter "yyyy-MM-dd HH:mm:ss") date-time)))
31 |
32 | (def all-pages-cache
33 | (atom (cache/ttl-cache-factory {} :ttl (* 3600 12)))) ; 12 hours
34 |
35 | (defn clear-project-cache!
36 | [project-id]
37 | (swap! all-pages-cache dissoc project-id))
38 |
39 | (defn render-hiccup-or-html
40 | [hiccup-or-html]
41 | (if (string? hiccup-or-html)
42 | (hiccup-util/raw-string hiccup-or-html)
43 | (walk/postwalk
44 | (fn [f]
45 | (cond
46 | (and (vector? f) (empty? f))
47 | nil
48 |
49 | (and (vector? f) (string? (first f)))
50 | (update f 0 keyword)
51 |
52 | :else
53 | f))
54 | hiccup-or-html)))
55 |
56 | (defn page-tags
57 | [project-id]
58 | (let [tags (take 10 (page/get-all-tags project-id))]
59 | (when (seq tags)
60 | [:ul
61 | (for [[tag _count] tags]
62 | [:li
63 | [:a.tag {:href (str "tag/" tag)}
64 | (str "#" tag)]])])))
65 |
66 | (defn subscribe-button
67 | []
68 | [:a.btn-subscribe
69 | {:target "_blank",
70 | :href "latest.rss"
71 | :style "margin-bottom: 10px"}
72 | [:svg
73 | {:viewbox "0 0 800 800"}
74 | [:path
75 | {:d
76 | "M493 652H392c0-134-111-244-244-244V307c189 0 345 156 345 345zm71 0c0-228-188-416-416-416V132c285 0 520 235 520 520z"}]
77 | [:circle {:r "71", :cy "581", :cx "219"}]]
78 | " Subscribe"])
79 |
80 | (defn back
81 | [project]
82 | [:a.back {:href (str "/" project)
83 | :title (str "Back to " project)}
84 | [:svg
85 | {:fill "currentColor", :viewbox "0 0 24 24", :height "36", :width "36"}
86 | [:path
87 | {:d
88 | "M9.70711 16.7071C9.31658 17.0976 8.68342 17.0976 8.29289 16.7071L2.29289 10.7071C1.90237 10.3166 1.90237 9.68342 2.29289 9.29289L8.29289 3.29289C8.68342 2.90237 9.31658 2.90237 9.70711 3.29289C10.0976 3.68342 10.0976 4.31658 9.70711 4.70711L5.41421 9H17C17.5523 9 18 9.44772 18 10C18 10.5523 17.5523 11 17 11L5.41421 11L9.70711 15.2929C10.0976 15.6834 10.0976 16.3166 9.70711 16.7071Z",
89 | :clip-rule "evenodd",
90 | :fill-rule "evenodd"}]]])
91 |
92 | (defn contributors-cp
93 | [contributors]
94 | (let [n (count contributors)]
95 | [:div.contributors.flex
96 | (for [{:keys [name avatar website]} contributors]
97 | (let [img [:img.avatar {:src avatar
98 | :alt name
99 | :class (if (= 1 n)
100 | "avatar-bigger"
101 | "")
102 | :onerror "this.style.display='none'"}]]
103 | (if website
104 | [:a.avatar {:href website
105 | :title name}
106 | img]
107 | img)))]))
108 |
109 | (defn project-card
110 | [project-name description contributors]
111 | (let [blog? (contains? #{"blog" "logseq blog"} (string/lower-case project-name))]
112 | [:div.project-card.flex-1.row.space-between.items-center
113 | [:div
114 | [:h1.title (if blog?
115 | "Logseq"
116 | project-name)]
117 | (if blog?
118 | [:div.description
119 | [:p "A local-first knowledge base which can sync using Github."]
120 | [:ul
121 | [:li "Twitter: " [:a {:href "https://twitter.com/logseq"}
122 | "@logseq"]]
123 | [:li "Github: " [:a {:href "https://github.com/logseq"}
124 | "@logseq"]]]]
125 | (when description
126 | (render-hiccup-or-html description)))
127 | (subscribe-button)]
128 | (if blog?
129 | [:img.avatar.avatar-bigger {:src "https://logseq.com/static/img/logo.png"
130 | :onerror "this.style.display='none'"}]
131 | (contributors-cp contributors))]))
132 |
133 | (defn build-project-index-page
134 | [project-id git-branch-name]
135 | (when-let [project (project/get-name-by-id project-id)]
136 | (let [{:keys [settings creator contributors]} (project/get-project-info project-id)
137 | contributors (cons creator contributors)
138 | {:keys [alias description header footer custom-css]} (:settings settings)
139 | pages (->> (page/get-project-pages project-id)
140 | (sort-by :published_at)
141 | (reverse))
142 | project-name (if-not (string/blank? alias) alias project)]
143 | (html/html5
144 | {:lang "en"}
145 | [:head
146 | [:meta {:http-equiv "content-type"
147 | :content "text/html;charset=UTF-8"}]
148 | [:meta {:name "viewport"
149 | :content "minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no"}]
150 | [:base {:href (str config/website-uri "/" project "/")}]
151 | [:title project-name]
152 | [:meta {:content "A local-first knowledge base.", :property "og:title"}]
153 | [:meta
154 | {:content
155 | "A local-first knowledge base which can be synced using Git.",
156 | :property "og:description"}]
157 | [:meta
158 | {:content "https://asset.logseq.com/static/img/logo.png",
159 | :property "og:image"}]
160 | [:link {:type "text/css"
161 | :href (util/asset-uri git-branch-name "/static/public.css")
162 | :rel "stylesheet"}]
163 | [:link
164 | {:href (util/asset-uri git-branch-name "/static/img/logo.png"),
165 | :type "image/png",
166 | :rel "shortcut icon"}]
167 | (when-not (string/blank? custom-css)
168 | [:style custom-css])
169 | [:link {:href "latest.rss"
170 | :rel "alternate"
171 | :type "application/rss+xml"
172 | :title (str "Latest posts of " project)}]
173 | [:body
174 | [:div.pub-page.index
175 | [:div.post
176 | (when header
177 | (render-hiccup-or-html header))
178 | (project-card project-name description contributors)
179 |
180 | (if (seq pages)
181 | (let [pages (group-by
182 | (fn [page] (-> (:published_at page)
183 | (tc/to-date-time)
184 | (t/year)))
185 | pages)]
186 | [:ul
187 | (for [[year pages] pages]
188 | [:li
189 | [:b year]
190 | [:ul
191 | (for [{:keys [permalink title published_at]} pages]
192 | [:li
193 | [:a {:href permalink}
194 | (util/capitalize-all title)]
195 | [:span.date (let [dt (tc/to-date-time published_at)]
196 | (str (t/month dt) "/" (t/day dt)))]])]])]))
197 |
198 | (page-tags project-id)
199 |
200 | (when footer
201 | (render-hiccup-or-html footer))]]]]))))
202 |
203 | (defn get-project-index-page
204 | [project-id git-branch-name]
205 | (str
206 | (if-let [html (get-in @all-pages-cache [project-id ::index])]
207 | html
208 | (let [html (build-project-index-page project-id git-branch-name)]
209 | (swap! all-pages-cache assoc-in [project-id ::index] html)
210 | html))))
211 |
212 | (defn build-project-tag-page
213 | [project-id tag git-branch-name]
214 | (when-let [project (project/get-name-by-id project-id)]
215 | (let [pages (page/get-pages-by-tag project-id tag)]
216 | (html/html5
217 | [:head
218 | [:meta {:http-equiv "content-type"
219 | :content "text/html;charset=UTF-8"}]
220 | [:meta
221 | {:content
222 | "minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no",
223 | :name "viewport"}]
224 | [:meta {:content (str "Tag " tag), :property "og:title"}]
225 | [:meta
226 | {:content "https://asset.logseq.com/static/img/logo.png",
227 | :property "og:image"}]
228 | [:base {:href (str config/website-uri "/" project "/")}]
229 | [:title (str "Tag " tag)]
230 | [:link {:type "text/css"
231 | :href (util/asset-uri git-branch-name "/static/public.css")
232 | :rel "stylesheet"}]
233 | [:link
234 | {:href (util/asset-uri git-branch-name "/static/img/logo.png"),
235 | :type "image/png",
236 | :rel "shortcut icon"}]
237 | [:body
238 | [:div.pub-page
239 | [:div.post.tags-page
240 | (back project)
241 | [:h1.title
242 | (str "#" tag)]
243 | (if (seq pages)
244 | [:ul
245 | (for [{:keys [permalink title published_at]} pages]
246 | [:li
247 | [:a {:href permalink}
248 | (util/capitalize-all title)]
249 | [:span.date (let [dt (tc/to-date-time published_at)]
250 | (str (t/month dt) "/" (t/day dt)))]])])]]]]))))
251 |
252 | (defn get-project-tag-page
253 | [project-id tag git-branch-name]
254 | (str
255 | (if-let [html (get-in @all-pages-cache [project-id ::tag tag])]
256 | html
257 | (let [html (build-project-tag-page project-id tag git-branch-name)]
258 | (swap! all-pages-cache assoc-in [project-id ::tag tag] html)
259 | html))))
260 |
261 | ;; TODO: might be slow
262 | (defn- unescape-html
263 | [s]
264 | (-> s
265 | (.replace "&" "&")
266 | (.replace "<" "<")
267 | (.replace ">" ">")
268 | (.replace """ "\"")))
269 |
270 | (defonce html-unsafe-tags
271 | #{:applet :base :basefont :frame :frameset :head :iframe :isindex
272 | :link :meta :object :param :script :style :title})
273 |
274 | (defn remove-unsafe-tags!
275 | [hiccup]
276 | (walk/postwalk
277 | (fn [x]
278 | (cond
279 | (and (vector? x)
280 | (contains? html-unsafe-tags (first x)))
281 | nil
282 |
283 | (string? x)
284 | (unescape-html x)
285 |
286 | :else
287 | x))
288 | hiccup))
289 |
290 | (defn html->hiccup
291 | [html]
292 | (->> (hickory/parse-fragment html)
293 | (map (comp remove-unsafe-tags! hickory/as-hiccup))
294 | (remove nil?)))
295 |
296 | (defn tags-cp
297 | [tags]
298 | [:p
299 | (for [tag tags]
300 | [:a {:href (str "tag/" tag)
301 | :style "margin-right: 10px"}
302 | (str "#" tag)])])
303 |
304 | (defn build-specific-page
305 | [project permalink {:keys [title html published_at updated_at] :as page} git-branch-name]
306 | (when-let [project-id (project/get-id-by-name project)]
307 | (let [{:keys [settings creator contributors]} (project/get-project-info project-id)
308 | contributors (cons creator contributors)
309 | {:keys [alias description header footer custom-css twitter]} (:settings settings)
310 | project-name (if-not (string/blank? alias) alias project)
311 | {:keys [slide highlight latex]} (:settings page)]
312 | (html/html5
313 | [:html {:prefix "og: http://ogp.me/ns#"
314 | "xmlns:og" "http://opengraphprotocol.org/schema/"}
315 | [:head
316 | [:meta {:http-equiv "content-type"
317 | :content "text/html;charset=UTF-8"}]
318 | [:meta
319 | {:content
320 | "minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no",
321 | :name "viewport"}]
322 | [:title (util/capitalize-all title)]
323 | [:meta {:name "author"
324 | :content project}]
325 | [:meta {:property "og:title"
326 | :content (util/capitalize-all title)}]
327 | [:meta
328 | {:content "https://asset.logseq.com/static/img/logo.png",
329 | :property "og:image"}]
330 | ;; TODO:
331 | ;; [:meta {:property "og:description"
332 | ;; :content ""}]
333 | [:meta {:property "og:url"
334 | :content (str "https://logseq.com/" project "/" permalink)}]
335 | [:meta {:property "og:type"
336 | :content "article"}]
337 | [:meta {:property "og:site_name"
338 | :content "logseq.com"}]
339 | [:meta {:property "article:published_time"
340 | :content (str published_at)}]
341 | [:meta {:name "twitter:card"
342 | :content "summary"}]
343 | [:meta {:name "twitter:title"
344 | :content title}]
345 | ;; TODO:
346 | ;; [:meta {:property "twitter:description"
347 | ;; :content ""}]
348 | (when twitter
349 | [:meta {:name "twitter:creator"
350 | :content (str "@" twitter)}])
351 |
352 | [:link {:type "text/css"
353 | :href (util/asset-uri git-branch-name "/static/public.css")
354 | :rel "stylesheet"}]
355 | [:link
356 | {:href (util/asset-uri git-branch-name "/static/img/logo.png"),
357 | :type "image/png",
358 | :rel "shortcut icon"}]
359 | (when-not (string/blank? custom-css)
360 | [:style custom-css])
361 | [:link {:href "latest.rss"
362 | :rel "alternate"
363 | :type "application/rss+xml"
364 | :title (str "Latest posts on " project)}]
365 | (when slide
366 | [:link {:type "text/css"
367 | :href (util/asset-uri git-branch-name "/static/css/reveal.min.css")
368 | :rel "stylesheet"}])
369 | (when slide
370 | [:link {:type "text/css"
371 | :href (util/asset-uri git-branch-name "/static/css/reveal_black.min.css")
372 | :rel "stylesheet"}])
373 | (when highlight
374 | [:link {:type "text/css"
375 | :href (util/asset-uri git-branch-name "/static/css/highlight.css")
376 | :rel "stylesheet"}])
377 | (when latex
378 | [:link {:type "text/css"
379 | :href (util/asset-uri git-branch-name "/static/css/katex.min.css")
380 | :rel "stylesheet"}])]
381 | [:body
382 | (if slide
383 | (html->hiccup html)
384 | [:div.pub-page
385 | [:div.post.article
386 | (if header
387 | (render-hiccup-or-html header)
388 | (back project))
389 | [:h1.title (util/capitalize-all title)]
390 | (let [tags (get-in page [:settings :tags])]
391 | (when (seq tags)
392 | (tags-cp tags)))
393 |
394 | (html->hiccup html)
395 | [:p.footer
396 | [:span {:title (str "Last modified at: " (full-sql-date->str updated_at))}
397 | (sql-date->str published_at)]]
398 |
399 | (if footer
400 | (render-hiccup-or-html footer)
401 | (project-card project-name description contributors))]])
402 | (when slide
403 | [:script {:defer true
404 | :src (util/asset-uri git-branch-name "/static/js/reveal.min.js")
405 | :onload "Reveal.initialize({ transition: 'slide' });"}])
406 | (when highlight
407 | [:script {:defer true
408 | :src (util/asset-uri git-branch-name "/static/js/highlight.min.js")
409 | :onload "hljs.initHighlightingOnLoad();"}])
410 | (when latex
411 | [:script {:defer true
412 | :src "https://cdn.jsdelivr.net/npm/katex@0.12.0/dist/katex.min.js"
413 | :integrity "sha384-g7c+Jr9ZivxKLnZTDUhnkOnsh30B4H0rpLUpJ4jAIKs4fnJI+sEnkvrMWph2EDg4"
414 | :crossorigin "anonymous"}])
415 | (when latex
416 | [:script {:defer true
417 | :src "https://cdn.jsdelivr.net/npm/katex@0.12.0/dist/contrib/auto-render.min.js"
418 | :integrity "sha384-mll67QQFJfxn0IYznZYonOWZ644AWYC+Pt2cHqMaRhXVrursRwvLnLaebdGIlYNa"
419 | :crossorigin "anonymous"
420 | :onload "renderMathInElement(document.body);"}])]]))))
421 |
422 | (defn get-page
423 | [project-id project permalink page git-branch-name]
424 | (str
425 | (if-let [html (get-in @all-pages-cache [project-id permalink])]
426 | html
427 | (let [html (build-specific-page project permalink page git-branch-name)]
428 | (swap! all-pages-cache assoc-in [project-id permalink] html)
429 | html))))
430 |
431 | (defn get-project-id-by-name
432 | [project-name]
433 | (let [project-id (and (string? project-name) (project/get-id-by-name project-name))]
434 | (if project-id
435 | (r/success project-id)
436 | (h/not-found "Project not found."))))
437 |
438 | (defn get-page-by-project-id-and-permalink
439 | [project-id permalink]
440 | (if-let [page (page/get-by-project-id-and-permalink project-id permalink)]
441 | (r/success page)
442 | (h/not-found "Page note found.")))
443 |
444 | (defn permit-user-to-access-page?
445 | [user page]
446 | (if (page/belongs-to? (:permalink page) (:id user))
447 | (r/success)
448 | (h/forbidden)))
449 |
450 | (defn delete-page
451 | [{:keys [app-context path-params] :as req}]
452 | (let [project-name (:project path-params)]
453 | (r/check-r
454 | (when-not (and (string? (:permalink path-params))
455 | project-name)
456 | (h/bad-request))
457 | (r/let-r [user (hu/login? app-context)
458 | permalink (codec/url-encode (:permalink path-params))
459 | project-id (get-project-id-by-name project-name)
460 | page (get-page-by-project-id-and-permalink project-id permalink)
461 | _ (permit-user-to-access-page? user page)]
462 | (page/delete (:id page))
463 | (interceptors/clear-user-cache (:id user))
464 | (clear-project-cache! project-id)
465 | (h/success true)))))
466 |
467 | (defn get-page-list
468 | [{:keys [app-context path-params] :as req}]
469 | (r/let-r [project-name (:name path-params)
470 | user (hu/login? app-context)
471 | project-id (get-project-id-by-name project-name)
472 | _ (hu/permit-to-access-project? user project-name)
473 | pages (page/get-all-by-project-id project-id)]
474 | (h/success pages)))
475 |
476 | (defn ->tags
477 | [tags]
478 | (vec (distinct (apply concat (map #(string/split % #",\s?") tags)))))
479 |
480 | (defn create-page
481 | [{:keys [app-context body-params] :as req}]
482 | (r/let-r [user (hu/login? app-context)
483 | user-id (:id user)
484 | {:keys [permalink title html settings project]} body-params
485 | project-id (get-project-id-by-name project)]
486 | (r/check-r
487 | (hu/permit-to-access-project? user project)
488 |
489 | (when (or (string/blank? title)
490 | (string/blank? html))
491 | (h/bad-request))
492 |
493 | (let [permalink (if (string/blank? permalink)
494 | (codec/url-encode title)
495 | (if (util/url-encoded? permalink)
496 | permalink
497 | (codec/url-encode permalink)))
498 | settings (update settings :tags ->tags)
499 | result (page/insert
500 | {:user_id user-id
501 | :project_id project-id
502 | :permalink permalink
503 | :title title
504 | :html html
505 | :settings settings})]
506 | (clear-project-cache! project-id)
507 | (when-let [old-permalink (:old_permalink settings)]
508 | (let [page (page/get-by-user-id-and-permalink user-id old-permalink)]
509 | (page/delete (:id page))))
510 | (h/success 201 result)))))
511 |
--------------------------------------------------------------------------------
/src/main/app/views/home.clj:
--------------------------------------------------------------------------------
1 | (ns app.views.home
2 | (:require [hiccup.page :as html]
3 | [app.config :as config]
4 | [app.util :as util]
5 | [cheshire.core :refer [generate-string]]
6 | [schema.utils :as utils]
7 | [app.github :as github]))
8 |
9 | (def logo
10 | [:svg {:width "42" :height "36" :viewbox "0 0 145 135" :fill "none" :xmlns "http://www.w3.org/2000/svg"} [:g {:filter "url(#filter0_bd)"} [:ellipse {:rx "21.0711" :ry "13.2838" :transform "matrix(0.988865 -0.148815 0.0688008 0.99763 81.142 18.3005)" :fill "#80a1bd"}] [:ellipse {:rx "18.9009" :ry "21.6068" :transform "matrix(-0.495846 0.86841 -0.825718 -0.564084 27.213 28.6018)" :fill "#80a1bd"}] [:g {:filter "url(#filter1_d)"} [:ellipse {:rx "49.827" :ry "39.2324" :transform "matrix(0.987073 0.160274 -0.239143 0.970984 85.5314 87.3209)" :fill "#80a1bd"}]]] [:defs [:filter#filter0_bd {:x "3.05615" :y "0.679199" :width "136.554" :height "133.572" :filterunits "userSpaceOnUse" :color-interpolation-filters "sRGB"} [:feflood {:flood-opacity "0" :result "BackgroundImageFix"}] [:fegaussianblur {:in "BackgroundImage" :stddeviation "2"}] [:fecomposite {:in2 "SourceAlpha" :operator "in" :result "effect1_backgroundBlur"}] [:fecolormatrix {:in "SourceAlpha" :type "matrix" :values "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"}] [:feoffset {:dy "4"}] [:fegaussianblur {:stddeviation "2"}] [:fecolormatrix {:type "matrix" :values "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"}] [:feblend {:mode "normal" :in2 "effect1_backgroundBlur" :result "effect2_dropShadow"}] [:feblend {:mode "normal" :in "SourceGraphic" :in2 "effect2_dropShadow" :result "shape"}]] [:filter#filter1_d {:x "31.4525" :y "48.3909" :width "108.158" :height "85.86" :filterunits "userSpaceOnUse" :color-interpolation-filters "sRGB"} [:feflood {:flood-opacity "0" :result "BackgroundImageFix"}] [:fecolormatrix {:in "SourceAlpha" :type "matrix" :values "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"}] [:feoffset {:dy "4"}] [:fegaussianblur {:stddeviation "2"}] [:fecolormatrix {:type "matrix" :values "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"}] [:feblend {:mode "normal" :in2 "BackgroundImageFix" :result "effect1_dropShadow"}] [:feblend {:mode "normal" :in "SourceGraphic" :in2 "effect1_dropShadow" :result "shape"}]]]])
11 |
12 | (def discord-logo
13 | [:svg.inline {:style {:margin-top -3}
14 | :width "32" :height "24" :viewbox "0 0 71 55" :fill "none" :xmlns "http://www.w3.org/2000/svg"} [:g {:clip-path "url(#clip0)"} [:path {:d "M60.1045 4.8978C55.5792 2.8214 50.7265 1.2916 45.6527 0.41542C45.5603 0.39851 45.468 0.440769 45.4204 0.525289C44.7963 1.6353 44.105 3.0834 43.6209 4.2216C38.1637 3.4046 32.7345 3.4046 27.3892 4.2216C26.905 3.0581 26.1886 1.6353 25.5617 0.525289C25.5141 0.443589 25.4218 0.40133 25.3294 0.41542C20.2584 1.2888 15.4057 2.8186 10.8776 4.8978C10.8384 4.9147 10.8048 4.9429 10.7825 4.9795C1.57795 18.7309 -0.943561 32.1443 0.293408 45.3914C0.299005 45.4562 0.335386 45.5182 0.385761 45.5576C6.45866 50.0174 12.3413 52.7249 18.1147 54.5195C18.2071 54.5477 18.305 54.5139 18.3638 54.4378C19.7295 52.5728 20.9469 50.6063 21.9907 48.5383C22.0523 48.4172 21.9935 48.2735 21.8676 48.2256C19.9366 47.4931 18.0979 46.6 16.3292 45.5858C16.1893 45.5041 16.1781 45.304 16.3068 45.2082C16.679 44.9293 17.0513 44.6391 17.4067 44.3461C17.471 44.2926 17.5606 44.2813 17.6362 44.3151C29.2558 49.6202 41.8354 49.6202 53.3179 44.3151C53.3935 44.2785 53.4831 44.2898 53.5502 44.3433C53.9057 44.6363 54.2779 44.9293 54.6529 45.2082C54.7816 45.304 54.7732 45.5041 54.6333 45.5858C52.8646 46.6197 51.0259 47.4931 49.0921 48.2228C48.9662 48.2707 48.9102 48.4172 48.9718 48.5383C50.038 50.6034 51.2554 52.5699 52.5959 54.435C52.6519 54.5139 52.7526 54.5477 52.845 54.5195C58.6464 52.7249 64.529 50.0174 70.6019 45.5576C70.6551 45.5182 70.6887 45.459 70.6943 45.3942C72.1747 30.0791 68.2147 16.7757 60.1968 4.9823C60.1772 4.9429 60.1437 4.9147 60.1045 4.8978ZM23.7259 37.3253C20.2276 37.3253 17.3451 34.1136 17.3451 30.1693C17.3451 26.225 20.1717 23.0133 23.7259 23.0133C27.308 23.0133 30.1626 26.2532 30.1066 30.1693C30.1066 34.1136 27.28 37.3253 23.7259 37.3253ZM47.3178 37.3253C43.8196 37.3253 40.9371 34.1136 40.9371 30.1693C40.9371 26.225 43.7636 23.0133 47.3178 23.0133C50.9 23.0133 53.7545 26.2532 53.6986 30.1693C53.6986 34.1136 50.9 37.3253 47.3178 37.3253Z" :fill "#ffffff"}]] [:defs [:clippath#clip0 [:rect {:width "71" :height "55" :fill "white"}]]]])
15 |
16 | (defn login
17 | [device]
18 | [:div.dropdown
19 | [:a.dropbtn.cursor-pointer
20 | {:onclick "login()"
21 | :class "text-base font-medium text-white hover:text-gray-300"} "Log in"]
22 | [:div.dropdown-content {:id (str device "-dropdown")}
23 | ;; [:a {:class "text-base text-white hover:text-gray-300"
24 | ;; :href (format "%s/login/google" config/website-uri)} "Login with Google"]
25 | [:a {:class "text-base text-white hover:text-gray-300"
26 | :href (format "%s/login/github" config/website-uri)} "Login with GitHub"]]])
27 |
28 | (defn app-download
29 | []
30 | ;; https://github.com/logseq/logseq/releases/latest/download/package.zip
31 | )
32 |
33 | (defn head
34 | [git-branch-name db-exists?]
35 | (let [css-href (if db-exists?
36 | (util/asset-uri git-branch-name "/static/css/style.css")
37 | "https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css")]
38 | [:head
39 | [:meta {:charset "utf-8"}]
40 | [:meta
41 | {:content
42 | "minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no",
43 | :name "viewport"}]
44 | [:link {:type "text/css", :href css-href, :rel "stylesheet"}]
45 | (when-not db-exists?
46 | [:style ".videoWrapper {
47 | position: relative;
48 | padding-bottom: 56.25%; /* 16:9 */
49 | height: 0;
50 | }
51 | .videoWrapper iframe {
52 | position: absolute;
53 | top: 0;
54 | left: 0;
55 | width: 100%;
56 | height: 100%;
57 | }
58 |
59 | .dropdown {
60 | position: relative;
61 | display: inline-block;
62 | }
63 |
64 | .dropdown-content {
65 | display: none;
66 | position: absolute;
67 | min-width: 160px;
68 | overflow: auto;
69 | z-index: 1;
70 | margin-top: 1rem;
71 | }
72 |
73 | .dropdown-content a {
74 | text-decoration: none;
75 | display: block;
76 | padding: 4px 0;
77 | }
78 |
79 | .show {display: block;}
80 | "])
81 | [:link
82 | {:href (util/asset-uri git-branch-name "/static/img/logo.png"),
83 | :type "image/png",
84 | :rel "shortcut icon"}]
85 | [:link
86 | {:href (util/asset-uri git-branch-name "/static/img/logo.png"),
87 | :sizes "192x192",
88 | :rel "shortcut icon"}]
89 | [:link
90 | {:href (util/asset-uri git-branch-name "/static/img/logo.png"),
91 | :rel "apple-touch-icon"}]
92 |
93 | ;; suggested by @denvey
94 | [:meta {:name "apple-mobile-web-app-title" :content "Logseq"}]
95 | [:meta {:name "apple-mobile-web-app-capable" :content "yes"}]
96 | [:meta {:name "apple-touch-fullscreen" :content "yes"}]
97 | [:meta {:name "apple-mobile-web-app-status-bar-style" :content "black-translucent"}]
98 | [:meta {:name "mobile-web-app-capable" :content "yes"}]
99 |
100 | [:meta {:content "summary", :name "twitter:card"}]
101 | [:meta
102 | {:content
103 | "A privacy-first, open-source platform for knowledge management and collaboration.",
104 | :name "twitter:description"}]
105 | [:meta {:content "@logseq", :name "twitter:site"}]
106 | [:meta {:content "A privacy-first, open-source knowledge base", :name "twitter:title"}]
107 | [:meta
108 | {:content "https://asset.logseq.com/static/img/logo.png",
109 | :name "twitter:image:src"}]
110 | [:meta
111 | {:content "A privacy-first, open-source platform for knowledge management and collaboration.", :name "twitter:image:alt"}]
112 | [:meta {:content "A privacy-first, open-source knowledge base", :property "og:title"}]
113 | [:meta {:content "site", :property "og:type"}]
114 | [:meta {:content "https://logseq.com", :property "og:url"}]
115 | [:meta
116 | {:content "https://asset.logseq.com/static/img/logo.png",
117 | :property "og:image"}]
118 | [:meta
119 | {:content
120 | "A privacy-first, open-source platform for knowledge management and collaboration.",
121 | :property "og:description"}]
122 | [:title "Logseq: A privacy-first, open-source knowledge base"]
123 | [:meta {:content "logseq", :property "og:site_name"}]
124 | [:meta
125 | {:description
126 | "A privacy-first, open-source platform for knowledge management and collaboration."}]]))
127 |
128 | (defn db-exists-page
129 | [user git-branch-name]
130 | (let [dev? config/dev?
131 | ks [:name :email :avatar :repos :projects :preferred_format :preferred_workflow :cors_proxy :github-authed?]
132 | user-json (-> (when user (select-keys user ks))
133 | (utils/assoc-when :git-branch git-branch-name)
134 | (generate-string))]
135 | (html/html5
136 | (head git-branch-name true)
137 | [:body
138 | [:div#root]
139 | ;; FIXME: safe?
140 | [:script (str "window.user=" user-json ";")]
141 | [:script {:src "/js/magic_portal.js"}]
142 | (let [s (format "let worker = new Worker(\"%s\");
143 | const portal = new MagicPortal(worker);
144 | ;(async () => {
145 | const git = await portal.get('git');
146 | window.git = git;
147 | const fs = await portal.get('fs');
148 | window.fs = fs;
149 | const pfs = await portal.get('pfs');
150 | window.pfs = pfs;
151 | const gitHttp = await portal.get('gitHttp');
152 | window.gitHttp = gitHttp;
153 | const workerThread = await portal.get('workerThread');
154 | window.workerThread = workerThread;
155 | })();
156 | "
157 | "/js/worker.js?v=3")]
158 | [:script s])
159 | ;; (when dev?
160 | ;; ;; react devtools
161 | ;; [:script {:src "http://localhost:8097"}])
162 |
163 | [:script {:src (util/asset-uri git-branch-name "/static/js/main.js")}]
164 | [:script {:src (util/asset-uri git-branch-name "/static/js/highlight.min.js")}]
165 | [:script {:src (util/asset-uri git-branch-name "/static/js/interact.min.js")}]])))
166 |
167 | (defn screenshot
168 | []
169 | [:div {:class "py-16 bg-gray-50 overflow-hidden lg:py-32"}
170 | [:h2 {:class "text-center text-3xl leading-8 font-extrabold tracking-tight text-gray-900 sm:text-4xl"}
171 | [:span "Your data is yours, forever!"]]
172 | [:p {:class "mt-4 max-w-3xl mx-auto text-center text-xl text-gray-500"}
173 | "No data lock-in, no proprietary formats, you can edit the same Markdown/Org-mode file with any tools at the same time."]
174 | [:div {:class "pt-16 bg-gray-50 overflow-hidden lg:pt-24"}
175 | [:div {:class "relative max-w-xl mx-auto px-4 sm:px-6 lg:px-8 lg:max-w-7xl"}
176 | [:a {:href "https://logseq.github.io"
177 | :title "Logseq documentation"}
178 | [:img.shadow-2xl.drop-shadow-2xl {:src "https://logseq.github.io/screenshots/1.png"}]]]]])
179 |
180 | (defn feature-1
181 | []
182 | [:div
183 | [:svg {:class "hidden lg:block absolute left-full transform -translate-x-1/2 -translate-y-1/4", :width "404", :height "784", :fill "none", :viewbox "0 0 404 784", :aria-hidden "true"}
184 | [:defs
185 | [:pattern {:id "b1e6e422-73f8-40a6-b5d9-c8586e37e0e7", :x "0", :y "0", :width "20", :height "20", :patternunits "userSpaceOnUse"}
186 | [:rect {:x "0", :y "0", :width "4", :height "4", :class "text-gray-200", :fill "currentColor"}]]]
187 | [:rect {:width "404", :height "784", :fill "url(#b1e6e422-73f8-40a6-b5d9-c8586e37e0e7)"}]]
188 | [:div {:class "relative lg:grid lg:grid-cols-2 lg:gap-8 lg:items-center"}
189 | [:div {:class "relative"}
190 | [:h3 {:class "text-2xl font-extrabold text-gray-900 tracking-tight sm:text-3xl"}
191 | "Connect your ideas like you do"]
192 | [:p {:class "mt-3 text-lg text-gray-500"} "Connect your [[ideas]] and [[thoughts]] with Logseq. Your knowledge graph grows just as your brain generates and connects neurons from new knowledge and ideas."]]
193 | [:div {:class "mt-10 -mx-4 relative lg:mt-0", :aria-hidden "true"}
194 | [:svg {:class "absolute left-1/2 transform -translate-x-1/2 translate-y-16 lg:hidden", :width "784", :height "404", :fill "none", :viewbox "0 0 784 404"}
195 | [:defs
196 | [:pattern {:id "ca9667ae-9f92-4be7-abcb-9e3d727f2941", :x "0", :y "0", :width "20", :height "20", :patternunits "userSpaceOnUse"}
197 | [:rect {:x "0", :y "0", :width "4", :height "4", :class "text-gray-200", :fill "currentColor"}]]]
198 | [:rect {:width "784", :height "404", :fill "url(#ca9667ae-9f92-4be7-abcb-9e3d727f2941)"}]]
199 | [:img {:class "relative mx-auto", :width "490", :src "https://logseq.github.io/gifs/connections.gif"}]]]])
200 |
201 | (defn feature-2
202 | []
203 | [:div
204 | [:svg {:class "hidden lg:block absolute right-full transform translate-x-1/2 translate-y-12", :width "404", :height "600", :fill "none", :viewbox "0 0 404 600", :aria-hidden "true"}
205 | [:defs
206 | [:pattern {:id "64e643ad-2176-4f86-b3d7-f2c5da3b6a6d", :x "0", :y "0", :width "20", :height "20", :patternunits "userSpaceOnUse"}
207 | [:rect {:x "0", :y "0", :width "4", :height "4", :class "text-gray-200", :fill "currentColor"}]]]
208 | [:rect {:width "404", :height "600", :fill "url(#64e643ad-2176-4f86-b3d7-f2c5da3b6a6d)"}]]
209 | [:div {:class "relative mt-12 sm:mt-16 lg:mt-24"}
210 | [:div {:class "lg:grid lg:grid-flow-row-dense lg:grid-cols-2 lg:gap-8 lg:items-center"}
211 | [:div {:class "lg:col-start-2"}
212 | [:h3 {:class "text-2xl font-extrabold text-gray-900 tracking-tight sm:text-3xl"}
213 | "Task management made easy"]
214 | [:p {:class "mt-3 text-lg text-gray-500"} "Organize your tasks and projects with built-in workflow commands like NOW/LATER/DONE, A/B/C priorities and repeated Scheduled/Deadlines. Moreover, Logseq comes with powerful query system to help you get insights and build your own workflow."]]
215 | [:div {:class "mt-10 -mx-4 relative lg:mt-0 lg:col-start-1"}
216 | [:svg {:class "absolute left-1/2 transform -translate-x-1/2 translate-y-16 lg:hidden", :width "784", :height "404", :fill "none", :viewbox "0 0 784 404", :aria-hidden "true"}
217 | [:defs
218 | [:pattern {:id "e80155a9-dfde-425a-b5ea-1f6fadd20131", :x "0", :y "0", :width "20", :height "20", :patternunits "userSpaceOnUse"}
219 | [:rect {:x "0", :y "0", :width "4", :height "4", :class "text-gray-200", :fill "currentColor"}]]]
220 | [:rect {:width "784", :height "404", :fill "url(#e80155a9-dfde-425a-b5ea-1f6fadd20131)"}]]
221 | [:img {:class "relative mx-auto", :width "490", :src "https://logseq.github.io/gifs/tasks.gif"}]]]]])
222 |
223 | (defn features
224 | []
225 | [:div {:class "pb-16 bg-gray-50 overflow-hidden lg:pb-24"}
226 | [:div {:class "relative max-w-xl mx-auto px-4 sm:px-6 lg:px-8 lg:max-w-7xl"}
227 | (feature-1)
228 |
229 | (feature-2)]])
230 |
231 | (defn testimonials
232 | []
233 | [:section {:class "py-16 lg:py-24 overflow-hidden"}
234 | [:h3 {:class "text-center text-3xl leading-8 font-extrabold tracking-tight text-gray-900 sm:text-4xl"}
235 | [:span "What People Are Saying"]]
236 |
237 | [:div {:class "mx-auto gap-4 md:grid md:grid-cols-3 md:px-6 lg:px-8 mt-4"}
238 | [:blockquote.twitter-tweet [:p {:lang "en" :dir "ltr"} "I'm using " [:a {:href "https://twitter.com/logseq?ref_src=twsrc%5Etfw"} "@logseq"] " + " [:a {:href "https://twitter.com/obsdmd?ref_src=twsrc%5Etfw"} "@obsdmd"] "! Logseq is an outliner + task management that works well with my \"18,000 things at once\" daily way of thinking. It's also connected to Obsidian, where I keep my longer term research that benefits from structured documents."] "— Jessica is trying her best (@heyitsliore) " [:a {:href "https://twitter.com/heyitsliore/status/1398354993657221122?ref_src=twsrc%5Etfw"} "May 28, 2021"]]
239 | [:blockquote.twitter-tweet [:p {:lang "en" :dir "ltr"} "Freaking *loving* " [:a {:href "https://twitter.com/logseq?ref_src=twsrc%5Etfw"} "@logseq"] " since I started using it on the desktop. " [:br] [:br] "Mashup of roam and org-mode's task management is making me very happy so far, even in 0.0.13. Super impressed as giving me all the things I like about roam, emacs org-mode, and notion together. ♥️"] "— Daryl Manning (@awws) " [:a {:href "https://twitter.com/awws/status/1374771771610603527?ref_src=twsrc%5Etfw"} "March 24, 2021"]]
240 | [:blockquote.twitter-tweet [:p {:lang "en" :dir "ltr"} "Every time I use " [:a {:href "https://twitter.com/logseq?ref_src=twsrc%5Etfw"} "@logseq"] " I'm thinking, I needed this years ago. And the gleefully look at a future where I had this for years. " [:br] [:br] "Video in the works"] "— Tools on Tech (@ToolsonTech) " [:a {:href "https://twitter.com/ToolsonTech/status/1378640136141950981?ref_src=twsrc%5Etfw"} "April 4, 2021"]]
241 |
242 | [:blockquote.twitter-tweet [:p {:lang "en" :dir "ltr"} "shout out to " [:a {:href "https://twitter.com/logseq?ref_src=twsrc%5Etfw"} "@logseq"] " if you haven't tried it! It is the best of Roam and Obsidian, in a format you are probably very used to already. Great community, amazing approachable devs, lightning fast development. Free, local, privacy focussed, outliner."] "— Luke Whitehead (@luque_whitehead) " [:a {:href "https://twitter.com/luque_whitehead/status/1395391019282276353?ref_src=twsrc%5Etfw"} "May 20, 2021"]]
243 |
244 | [:blockquote.twitter-tweet [:p {:lang "en" :dir "ltr"} [:a {:href "https://twitter.com/logseq?ref_src=twsrc%5Etfw"} "@logseq"] ", you've stolen my heart (well, maybe neurons). a thread... 🧵"] "— daytura // ladon n. (@ArchLeucoryx) " [:a {:href "https://twitter.com/ArchLeucoryx/status/1394182088576749568?ref_src=twsrc%5Etfw"} "May 17, 2021"]]
245 |
246 | [:blockquote.twitter-tweet [:p {:lang "en" :dir "ltr"} "Ok, I'm having an absolute BLAST using " [:a {:href "https://twitter.com/logseq?ref_src=twsrc%5Etfw"} "@logseq"] " for my DevLog and Tech notes that need more structure and organization. " [:br] [:br] "the MVP for the project was just do new daily DevLog notes and record useful info, started some porting yesterday from " [:a {:href "https://twitter.com/obsdmd?ref_src=twsrc%5Etfw"} "@obsdmd"] [:a {:href "https://twitter.com/logseq?ref_src=twsrc%5Etfw"} "@logseq"] " is 🔥LIT🔥 " [:a {:href "https://t.co/F8WRLwmIs7"} "pic.twitter.com/F8WRLwmIs7"]] "— Bryan Jenks 🌱️ ⠕ (@tallguyjenks) " [:a {:href "https://twitter.com/tallguyjenks/status/1392943684329480192?ref_src=twsrc%5Etfw"} "May 13, 2021"]]]])
247 |
248 | (defn join-community
249 | []
250 | [:div.bg-white
251 | [:div.max-w-7xl.mx-auto.text-center.py-12.px-4.sm:px-6.lg:py-16.lg:px-8
252 | [:h2.text-3xl.font-extrabold.tracking-tight.text-gray-900.sm:text-4xl
253 | [:span.block "Ready to dive in?"]]
254 | [:p {:class "mt-4 max-w-3xl mx-auto text-center text-xl text-gray-500"}
255 | "Join the discord group to chat with the makers and our helpful community members."]
256 | [:div.mt-8.flex.justify-center
257 | [:div.inline-flex.rounded-md.shadow [:a.inline-flex.items-center.justify-center.px-5.py-3.border.border-transparent.text-base.font-medium.rounded-md.text-white.bg-blue-600.hover:bg-blue-700 {:href "https://discord.gg/KpN4eHY"}
258 | [:span
259 | discord-logo
260 | [:span.ml-2 "Join the community"]]]]
261 | [:div.ml-3.inline-flex [:a.inline-flex.items-center.justify-center.px-5.py-3.border.border-transparent.text-base.font-medium.rounded-md.text-blue-700.bg-blue-100.hover:bg-blue-200 {:href "https://logseq.github.io"} "Check documentation"]]]]])
262 |
263 | (defn footer
264 | []
265 | [:footer.bg-gray-800 {:aria-labelledby "footerHeading"}
266 | [:h2#footerHeading.sr-only "Footer"]
267 | [:div.max-w-7xl.mx-auto.py-12.px-4.sm:px-6.lg:py-16.lg:px-8
268 | [:div.text-gray-400.mb-2
269 | [:a {:href "https://www.producthunt.com/posts/logseq"} "Leave your review on Product Hunt"]]
270 | [:div.md:flex.md:items-center.md:justify-between
271 | [:div.flex.space-x-6.md:order-2
272 | [:a.text-gray-400.hover:text-gray-300 {:href "https://twitter.com/logseq"} [:span.sr-only "Twitter"] [:svg.h-6.w-6 {:fill "currentColor" :viewbox "0 0 24 24" :aria-hidden "true"} [:path {:d "M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84"}]]]
273 | [:a.text-gray-400.hover:text-gray-300 {:href "https://github.com/logseq/logseq"} [:span.sr-only "GitHub"] [:svg.h-6.w-6 {:fill "currentColor" :viewbox "0 0 24 24" :aria-hidden "true"} [:path {:fill-rule "evenodd" :d "M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" :clip-rule "evenodd"}]]]]
274 | [:p.mt-8.text-base.text-gray-400.md:mt-0.md:order-1 "© 2021 Logseq"
275 | [:span
276 | [:a.ml-2 {:href "https://logseq.github.io/#/page/Privacy%20Policy"} "Privacy"]
277 | [:a.ml-2 {:href "https://logseq.github.io/#/page/Terms"} "Terms"]
278 | [:a.ml-2 {:href "mailto:hi@logseq.com"} "Contact us"]]]]]])
279 |
280 | (defn hero
281 | [release-assets]
282 | [:div {:class "pt-10 bg-gray-900 sm:pt-16 lg:pt-8 lg:pb-14 lg:overflow-hidden"}
283 | [:div {:class "mx-auto max-w-7xl lg:px-8"}
284 | [:div {:class "lg:grid lg:grid-cols-2 lg:gap-8"}
285 | [:div {:class "mx-auto max-w-md px-4 sm:max-w-2xl sm:px-6 sm:text-center lg:px-0 lg:text-left lg:flex lg:items-center"}
286 | [:div {:class "lg:py-24"}
287 | [:span.mt-2 {:class "inline-flex items-center text-white bg-black rounded-full p-1 pr-2 sm:text-base lg:text-sm xl:text-base hover:text-gray-200"}
288 | [:span {:class "px-3 py-0.5 text-white text-xs font-semibold leading-5 uppercase tracking-wide bg-blue-500 rounded-full"} "Beta testing"]
289 | [:a {:href "https://www.youtube.com/watch?v=Pji6_0pbHFw"}
290 | [:span {:class "ml-4 text-sm"} "Why logseq?"]]
291 | [:svg {:class "ml-2 w-5 h-5 text-gray-500", :xmlns "http://www.w3.org/2000/svg", :viewbox "0 0 20 20", :fill "currentColor", :aria-hidden "true"}
292 | [:path {:fill-rule "evenodd", :d "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", :clip-rule "evenodd"}]]]
293 | [:h1 {:class "mt-4 text-4xl tracking-tight font-extrabold text-white sm:mt-5 sm:text-6xl lg:mt-6 xl:text-6xl"}
294 | [:span "A privacy-first, open-source knowledge base"]]
295 |
296 | [:p {:class "mt-3 text-base text-gray-300 sm:mt-5 sm:text-xl lg:text-lg xl:text-xl"} "Logseq is a joyful, open-source outliner that works on top of local plain-text Markdown and Org-mode files. Use it to write, organize and share your thoughts, keep your to-do list, and build your own digital garden."]
297 | [:div.mt-10.sm:flex.sm:justify-center.lg:justify-start
298 | [:div.rounded-md.shadow
299 | [:a.btn-download-os.w-full.flex.items-center.justify-center.px-8.py-3.border.border-transparent.text-base.font-medium.rounded-md.text-white.bg-blue-600.hover:bg-blue-700.md:py-4.md:text-lg.md:px-10 {:href "https://github.com/logseq/logseq/releases"}
300 | "Download desktop app"]]
301 | [:div.mt-3.rounded-md.shadow.sm:mt-0.sm:ml-3
302 | [:a.btn-open-local-folder.w-full.flex.items-center.justify-center.px-8.py-3.border.border-transparent.text-base.font-medium.rounded-md.text-blue-600.bg-white.hover:bg-gray-50.md:py-4.md:text-lg.md:px-10 {:href "https://logseq.com?nfs=true"}
303 | "Live Demo"]]]
304 | [:div.mt-2.text-white.text-sm
305 | [:p "For Macs with Apple Silicon chips, click "
306 | [:a.text-blue-300.hover:text-blue-500 {:href (get release-assets "Mac-M1")}
307 | [:span "here"]]
308 | " to download"]]]]
309 | [:div {:class "-mb-16 sm:-mb-48 lg:m-0 lg:relative"
310 | :style {:margin-top "12rem"}}
311 | [:div.videoWrapper
312 | [:iframe {:width "560", :height "315", :src "https://www.youtube.com/embed/SUOdfa3MucE", :title "YouTube video player", :frameborder "0", :allow "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture", :allowfullscreen true}]]]]]])
313 |
314 | (defn db-not-exists-page
315 | [user git-branch-name]
316 | (let [dev? config/dev?
317 | release (github/get-logseq-latest-release)
318 | release-assets (generate-string release)]
319 | (html/html5
320 | (head git-branch-name false)
321 | [:body
322 | [:div#root
323 | [:div {:class "min-h-screen"}
324 | [:div {:class "relative overflow-hidden"}
325 | [:header {:class "relative"}
326 | [:div {:class "bg-gray-900 pt-6"}
327 | [:nav {:class "relative max-w-7xl mx-auto flex items-center justify-between px-4 sm:px-6", :aria-label "Global"}
328 | [:div {:class "flex items-center flex-1"}
329 | [:div {:class "flex items-center justify-between w-full md:w-auto"}
330 | [:a {:href "#"}
331 | [:span {:class "sr-only"} "Logseq"]
332 | logo]
333 | [:div {:class "-mr-2 flex items-center md:hidden"}
334 | (login "mobile")]]
335 | [:div {:class "hidden space-x-8 md:flex md:ml-10"}
336 | [:a {:href "https://opencollective.com/logseq", :class "text-base font-medium text-white hover:text-gray-300"} "Pricing"]
337 | [:a {:href "https://discord.gg/KpN4eHY", :class "text-base font-medium text-white hover:text-gray-300"} "Community"]
338 | [:a {:href "https://discuss.logseq.com/", :class "text-base font-medium text-white hover:text-gray-300"} "Forum"]
339 | [:a {:href "https://logseq.github.io", :class "text-base font-medium text-white hover:text-gray-300"} "Documentation"]
340 | [:a {:href "https://trello.com/b/8txSM12G/logseq-roadmap", :class "text-base font-medium text-white hover:text-gray-300"} "Roadmap"]]]
341 | [:div {:class "hidden md:flex md:items-center md:space-x-6"}
342 | [:div.pt-2
343 | [:a {:class "github-button", :href "https://github.com/logseq/logseq", :data-color-scheme "no-preference: dark; light: dark_dimmed; dark: dark_dimmed;", :data-show-count "true", :aria-label "Star logseq/logseq on GitHub"} "Star"]]
344 | (login "desktop")]]]]
345 | [:main
346 | (hero release)
347 |
348 | (screenshot)
349 |
350 | (features)
351 |
352 | (join-community)
353 |
354 | (testimonials)
355 |
356 | (footer)]
357 |
358 | [:script {:async true :defer true :src "https://buttons.github.io/buttons.js"}]
359 | [:script {:async true :defer true :src "https://platform.twitter.com/widgets.js" :charset "utf-8"}]
360 | [:script (str "window.releaseAssets=" release-assets ";")]
361 | [:script (format
362 | "let userAgent = navigator.appVersion;
363 | let osDetails = {
364 | name: 'Unknown OS',
365 | icon: 'fa-question-circle'
366 | };
367 |
368 | if (userAgent.includes('Macintosh')) {
369 | osDetails.name = 'Mac';
370 | }
371 |
372 | if (userAgent.includes('Windows')) {
373 | osDetails.name = 'Windows';
374 | }
375 |
376 | if (userAgent.includes('Linux')) {
377 | osDetails.name = 'Linux';
378 | }
379 |
380 | if (userAgent.includes('iPhone')) {
381 | osDetails.name = 'iPhone';
382 | }
383 |
384 | if (userAgent.includes('Android')) {
385 | osDetails.name = 'Android';
386 | }
387 | updateOsDownloadButton(osDetails);
388 |
389 | function updateOsDownloadButton(osDetails) {
390 | let releaseAssets = window.releaseAssets;
391 | let button = document.querySelector('.btn-download-os');
392 | button.href = releaseAssets[osDetails.name] || 'https://github.com/logseq/logseq/releases';
393 | }
394 |
395 | function nfsSupported() {
396 | if ('chooseFileSystemEntries' in self) {
397 | return 'chooseFileSystemEntries'
398 | } else if ('showOpenFilePicker' in self) {
399 | return 'showOpenFilePicker'
400 | }
401 | return false
402 | }
403 |
404 | let isnfssupported = nfsSupported();
405 | if (isnfssupported) {
406 | let node = document.querySelector('.btn-open-local-folder');
407 | node.innerHTML = 'Open a local folder';
408 | node.href = '%s?spa=true';
409 | } else {
410 | let node = document.querySelector('.btn-open-local-folder');
411 | node.innerHTML = 'Live Demo'
412 | node.href = '%s?spa=true'
413 | }
414 |
415 | function login() {
416 | document.getElementById(\"desktop-dropdown\").classList.toggle(\"show\");
417 | document.getElementById(\"mobile-dropdown\").classList.toggle(\"show\");
418 | }
419 |
420 | // Close the dropdown if the user clicks outside of it
421 | window.onclick = function(event) {
422 | if (!event.target.matches('.dropbtn')) {
423 | var dropdowns = document.getElementsByClassName(\"dropdown-content\");
424 | var i;
425 | for (i = 0; i < dropdowns.length; i++) {
426 | var openDropdown = dropdowns[i];
427 | if (openDropdown.classList.contains('show')) {
428 | openDropdown.classList.remove('show');
429 | }
430 | }
431 | }
432 | }
433 | "
434 | config/website-uri
435 | config/website-uri)]]]]])))
436 |
437 | (defn home
438 | [user db-exists? git-branch-name]
439 | (if db-exists?
440 | (db-exists-page user git-branch-name)
441 | (db-not-exists-page user git-branch-name)))
442 |
--------------------------------------------------------------------------------