Fosi Fossil Service
Small teams. Open source. Built on Fossil.

CaaS / Lamina

Unified Management for Fossil Repositories

Public | Owner: CaaS | Default branch: trunk | Last update: 2026-10-25 00:41

Artifact 71c4f7c3a386fa1c22ea3b93d612252528b336a967cd105f2e47d93f4a6bf503:


;; 站点信息与公开首页数据聚合。
(defmodule lamina_site_mgr
  (export all))

;;---- 站点设置 ----

(defun defaults ()
  #M(site_name "lamina"
      site_url "http://localhost:8827"
      site_tagline "Small teams. Open source. Built on Fossil."
      site_description "Open source projects and tools powered by Fossil."
      footer_text "lamina — a small, focused Fossil management shell."
      footer_terms_url ""
      footer_privacy_url ""
      footer_contact_url ""
      nav_menu ""))

(defun get (key default)
  (case (catch (lamina_db:q "SELECT value FROM site_settings WHERE key=?;"
                              (list key)))
    (`((,value)) (to-str value))
    (_
     (default-value key default))))

;; Setup 向导首先把站点名称与 URL 写入运行时配置;数据库设置表尚未有值时,
;; 公开首页和新建仓库皮肤也应该沿用这两个值。
(defun default-value (key fallback)
  (case key
    ('site_name (lamina_config:get 'site_name fallback))
    ('site_url (lamina_config:get 'site_url fallback))
    (_
     (case (maps:find key (defaults))
       (`#(ok ,value) value)
       (_ fallback)))))

(defun all ()
  (lists:foldl
   (lambda (entry acc)
     (let (((tuple key default) entry))
       (maps:put key (get key default) acc)))
   #M()
   (list (tuple 'site_name "lamina")
         (tuple 'site_url "http://localhost:8827")
         (tuple 'site_tagline "Small teams. Open source. Built on Fossil.")
         (tuple 'site_description "Open source projects and tools powered by Fossil.")
         (tuple 'footer_text "lamina — a small, focused Fossil management shell.")
         (tuple 'footer_terms_url "")
         (tuple 'footer_privacy_url "")
         (tuple 'footer_contact_url "")
         (tuple 'nav_menu ""))))

(defun set (key value)
  (let ((value1 (to-str value)))
    (case (catch (lamina_db:q
                  "INSERT OR REPLACE INTO site_settings(key,value) VALUES(?,?);"
                  (list key value1)))
      ('ok 'ok)
      ('() 'ok)
      (`#(error ,reason) `#(error ,(format-db-error reason)))
      (_ `#(error "保存站点设置失败")))))

(defun update (values)
  (let ((entries (maps:to_list values)))
    (save-settings entries)))

(defun save-settings (entries)
  (case entries
    ('() 'ok)
    (`(#(,key ,value) . ,rest)
     (case (set key value)
       ('ok (save-settings rest))
       (error error)
       (_ `#(error "保存站点设置失败"))))))

(defun site-name () (get 'site_name "lamina"))
(defun site-url () (get 'site_url "http://localhost:8827"))
(defun site-tagline () (get 'site_tagline "Small teams. Open source. Built on Fossil."))

;; 自定义顶栏菜单。每行 "名称 | URL",Explore 始终在最前。
(defun nav-items ()
  (parse-nav-menu (get 'nav_menu "")))

(defun parse-nav-menu (text)
  (lists:filtermap
   (lambda (line)
     (parse-nav-line (string:trim (to-str line))))
   (string:split (to-str text) "\n" 'all)))

(defun parse-nav-line (line)
  (if (=:= "" line)
      'false
      (case (string:split line "|" 'leading)
        (`(,label ,url)
         (let ((name (string:trim label))
               (href (string:trim url)))
           (if (and (/= "" name) (/= "" href) (valid-nav-url? href))
               `#(true ,(maps:from_list (list (tuple 'label name) (tuple 'url href))))
               'false)))
        (_ 'false))))

(defun valid-nav-url? (url)
  (or (lists:prefix "https://" url)
      (lists:prefix "http://" url)
      (lists:prefix "mailto:" url)
      (and (lists:prefix "/" url) (not (lists:prefix "//" url)))))

;; 管理员从 Settings 页面修改平台密码。平台目前使用 OTP 内置的
;; PBKDF2-HMAC-SHA512(见 lamina_crypto),避免引入原生 argon2 依赖。
(defun change-admin-password (username current-password new-password confirmation)
  (let* ((username1 (to-str username))
         (current1 (to-bin current-password))
         (new1 (to-bin new-password))
         (confirm1 (to-bin confirmation)))
    (cond
      ((=:= #"" current1) `#(error "当前密码不能为空"))
      ((< (byte_size new1) 8) `#(error "新密码至少需要 8 个字符"))
      ((/= new1 confirm1) `#(error "两次输入的新密码不一致"))
      ('true
       (case (catch (lamina_db:q
                     "SELECT password_hash FROM admin_user WHERE username=?;"
                     (list username1)))
         (`((,stored))
          (if (lamina_crypto:verify_password current1 (to-bin stored))
              (let ((result (lamina_db:q
                             "UPDATE admin_user SET password_hash=?,updated_at=? WHERE username=?;"
                             (list (lamina_crypto:hash_password new1)
                                   (lamina_setup:iso8601 (os:system_time 'second))
                                   username1))))
                (case result
                  ('ok (lamina_auth:reset-fails) 'ok)
                  ('() (lamina_auth:reset-fails) 'ok)
                  (`#(error ,reason) `#(error ,(format-db-error reason)))
                  (_ `#(error "保存管理员密码失败"))))
              `#(error "当前密码不正确")))
         (_ `#(error "管理员账户不存在")))))))

;; 重新渲染并导入所有仓库的统一皮肤。失败项全部收集后返回,方便设置页
;; 给出可读反馈,同时不会因为单个损坏仓库而中断剩余仓库。
(defun reapply-skin-all ()
  (case (catch (lamina_db:q
                "SELECT id,organization_id,name,fossil_path,description FROM repositories ORDER BY id;"))
    (rows (let (((tuple count errors)
                 (lists:foldl #'reapply-one/2 (tuple 0 '()) rows)))
            (if (=:= '() errors)
                `#(ok ,count)
                `#(error ,(lists:reverse errors)))))
    (`#(error ,reason) `#(error ,(format-db-error reason)))
    (_ `#(error "读取仓库列表失败"))))

(defun reapply-one (row acc)
  (let (((tuple count errors) acc))
    (case row
      (`(,_id ,org-id ,slug ,path ,desc)
       (let ((result (catch (lamina_repo_ctl:maybe-apply-skin
                             (to-str path) (normalize-org-id org-id) (to-str slug)
                             (to-str desc)))))
         (case result
           ('ok (tuple (+ count 1) errors))
           (`#(error ,reason) (tuple count (cons (list (to-str slug) ": " (to-str reason)) errors)))
           (`#(EXIT ,reason) (tuple count (cons (list (to-str slug) ": " (to-str reason)) errors)))
           (_ (tuple count (cons (list (to-str slug) ": 皮肤导入失败") errors))))))
      (_ acc))))

(defun normalize-org-id (value)
  (case value
    ('undefined 'none)
    ('null 'none)
    (_ value)))

;; repo_server 使用该 URL,让 Fossil 生成的内部链接和反向代理前缀一致。
(defun repo-base-url (org-name slug)
  (let ((root (trim-right-slash (site-url)))
        (path (case org-name
                ('none (lists:flatten (list "/" (to-str slug))))
                (#"" (lists:flatten (list "/" (to-str slug))))
                ("" (lists:flatten (list "/" (to-str slug))))
                (org (lists:flatten (list "/" (to-str org) "/" (to-str slug)))))))
    (lists:flatten (list root path))))

;;---- 公开首页 ----

(defun public-index ()
  (let* ((result (catch
                   (lamina_db:q
                    "SELECT r.name,r.display_name,r.description,COALESCE(r.last_timeline_at,''),r.updated_at,r.fossil_path,COALESCE(o.name,''),COALESCE(o.created_at,'') FROM repositories r LEFT JOIN organizations o ON o.id=r.organization_id;")))
         (rows (if (is_list result) result '()))
         (hidden (lamina_library:hidden-paths))
         (summaries (lists:filter
                     (lambda (summary) (not (hide-summary? summary hidden)))
                     (lists:map #'row->summary/1 rows))))
    (maps:from_list
     (list (tuple 'site (all))
           (tuple 'groups (build-groups summaries))))))

(defun hide-summary? (summary hidden-paths)
  (lists:member (element 8 summary) hidden-paths))

;; summary: {org-name, org-created, slug, display-name, description,
;;           last-timeline, updated-at, fossil-path}
(defun row->summary (row)
  (tuple (to-str (lists:nth 7 row))
         (to-str (lists:nth 8 row))
         (to-str (lists:nth 1 row))
         (to-str (lists:nth 2 row))
         (to-str (lists:nth 3 row))
         (to-str (lists:nth 4 row))
         (to-str (lists:nth 5 row))
         (to-str (lists:nth 6 row))))

(defun build-groups (summaries)
  (let ((ordered (lists:sort #'summary-less?/2 summaries)))
    (lists:map #'group->map/1 (group-rows ordered '()))))

(defun summary-less? (a b)
  (let ((a-org (element 1 a))
        (b-org (element 1 b)))
    (cond
      ((and (=:= "" a-org) (/= "" b-org)) 'true)
      ((and (/= "" a-org) (=:= "" b-org)) 'false)
      ((=:= a-org b-org)
       (let ((a-updated (element 7 a))
             (b-updated (element 7 b)))
         (if (=:= a-updated b-updated)
             (=< (element 3 a) (element 3 b))
             (>= a-updated b-updated))))
      ('true
       (let ((a-created (element 2 a))
             (b-created (element 2 b)))
         (if (=:= a-created b-created)
             (=< a-org b-org)
             (=< a-created b-created)))))))

(defun group-rows (summaries groups)
  (case summaries
    ('() groups)
    (`(,summary . ,rest)
     (group-rows rest (add-to-group summary groups)))))

(defun add-to-group (summary groups)
  (let ((org (element 1 summary)))
    (case groups
      ('() (list (tuple org (element 2 summary) (list summary))))
      (`(#(,group-org ,created ,repos) . ,rest)
       (if (=:= org group-org)
           (cons (tuple group-org created (add-repo summary repos)) rest)
           (cons (tuple group-org created repos)
                 (add-to-group summary rest)))))))

(defun add-repo (summary repos)
  (case repos
    ('() (list summary))
    (`(,first . ,rest)
     (if (summary-less? summary first)
         (cons summary repos)
         (cons first (add-repo summary rest))))))

(defun group->map (group)
  (let (((tuple org _created summaries) group))
    (maps:from_list
     (list (tuple 'title (if (=:= "" org) "project" org))
           (tuple 'repos (lists:map #'summary->map/1 summaries))))))

(defun summary->map (summary)
  (let (((tuple org _created slug display description last updated path) summary))
    (let ((display1 (if (=:= "" display) slug display)))
      (maps:from_list
       (list (tuple 'name slug)
             (tuple 'display_name display1)
             (tuple 'description description)
             (tuple 'organization org)
             (tuple 'url (repo-url org slug))
             (tuple 'last_timeline (timeline-value last path updated))
             (tuple 'updated_at updated))))))

(defun timeline-value (last path updated)
  (cond
    ((/= "" (to-str last)) (to-str last))
    ('true
     (case (lamina_fossil_repo:last-timeline path)
       ('unknown (format-updated updated))
       (value (to-str value))))))

(defun format-updated (updated)
  (let ((s (to-str updated)))
    (case (re:run s "^([0-9]{4}-[0-9]{2}-[0-9]{2})[T ]([0-9]{2}:[0-9]{2})"
                  (list (tuple 'capture 'all 'list)))
      (`#(match (,_ ,date ,time)) (lists:flatten (list date " " time)))
      (_ (if (=:= "" s) "No activity yet" s)))))

(defun repo-url (org slug)
  (if (=:= "" org)
      (lists:flatten (list "/" slug))
      (lists:flatten (list "/" org "/" slug))))

(defun trim-right-slash (value)
  (let ((s (to-str value)))
    (if (and (> (length s) 0) (=:= (lists:last s) 47))
        (trim-right-slash (lists:sublist s 1 (- (length s) 1)))
        s)))

(defun format-db-error (reason)
  (lists:flatten (io_lib:format "数据库错误: ~p" (list reason))))

(defun to-bin (value)
  (if (is_binary value) value (list_to_binary (to-str value))))

(defun to-str (value)
  (cond
    ((is_binary value) (binary_to_list value))
    ((is_list value) value)
    ((or (=:= 'undefined value) (=:= 'null value)) "")
    ('true (lists:flatten (io_lib:format "~p" (list value))))))

Clone

You can clone this repository with the following command:

fossil clone https://fosi.dev/CaaS/Lamina

Contribute

The best way to contribute is through e-mail. See the project documentation for more details.