├── 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 | --------------------------------------------------------------------------------