matrix 菜鳥使用心得

一直想架一個自由的即時通訊軟體, 之前 大概研究了幾個自由的 im , 最後因為有社團的資源可以蹭,所以架了聯邦制的 im matrix。 matrix 在架構上介於 discord(或 slack)與 irc 的中間, 但因為聯邦制和注重隱私,又多了一些不便。 加上很多功能還在開發中,會少一些功能,需要手動操作。 目前要直接從 discord 搬過來 matrix 會不太方便。 本文介紹了 matrix 的基本概念、使用經驗、與 discord 的比較, 最後是如何以 restful api 管理伺服器。

matrix 命名空間

matrix 是所謂聯邦制,在不同 server 上註冊的使用者可以互相溝通。 所有的資源都是掛在某一個 server 下, 包括使用者、頻道、頻道群組(像 discord 的 server 或說 guild), 但使用者仍可存取不同的 server 上的資源。


matrix 上的名稱建構方式,像是 irc 和 twitter 的綜合版, 再加上用冒號來後綴伺服器名來達成, 在同一個伺服器上則可以省去後綴的伺服器名。

伺服器名即是域名,例如 ccns 的伺服器名是 ccns.io , 那建立在 ccns.io 上的 general 頻道完整名稱即寫成 #general:ccns.io 。 而註冊在 ccns 上的使用者 gholk 即是 @gholk:ccns.io 。 和 xmpp 使用的 email 風格名稱 gholk@ccns.io 不太一樣。

頻道

頻道也就是群組聊天,是聊天的基本單位; 可以是一對一,也可以是多對一; 可以是公開,也可以是私密或端對端加密。 頻道也可以有地址,有地址的頻道方可以用井號加伺服器名的方式表示, 這類有地址的頻道就比較像 irc discord 或 telegram 上的頻道。 當然,伺服器管理者可以直接管理地址, 或控制是否允許使用者建立具有地址的頻道。 不然註冊像 #admin:ccns.io 之類的頻道會讓管理者很困擾。

在地址之外,也可以有單純用來顯示的頻道名稱。 其它像一對一的聊天,或是加了多個人的群聊, 預設是沒有地址也沒有名稱的,也是預設加密的。 名稱和地址可以事後再加上,但加密是不能解除。 這關係到所有加密的是端對端加密, 伺服器上保存的只是加密的資料, 所以沒辦法做到解除加密的操作。

頻道的公開程度可以有三種, 可被搜尋與加入、可用連結加入、需要被邀請才能加入。 和加密的選項是獨立的。

一個頻道也可以有多個地址, 雖然我想不到這功能有什麼應用。 目前我們的 discord bridge bot 會自動用 discord 的頻道 id 來當作頻道的地址, 如果我們想把 discord 上的 general 也當作 matrix 的 general, 就必須要在 id 之外再加上額外的地址 general。

頻道權限

頻道的權限是用分數決定的。 最高分者是是管理者,可以有絕對的權限,預設建立者是 100 分。 每個人可以調整較低分者的權限,最高到和自己同分為止。

每人預設是 0 分,moderator 是 50 分,admin 是 100 分, 頻道設定裡可以調不同的操作需要哪種身份, 像只有 admin 能 invite 或 moderator 可以刪文之類的。

admin 可以把人升到 admin,但 admin 之間不能互踢之類的。 此外 matrix 目前也沒有跨頻道的權限管理機制, 唯一比較像跨頻道的一個是 server admin 可以直接把人加入或踼出頻道, 且也可以把人直接升為頻道管理員。

頻道群組

有個東西叫 community,有點像 discord 上的 server, 可以把幾個頻道放到同一個群組裡。 群組是用加號開頭,可以有 avatar、html 的介紹。 使用者只要點選群組的圖示,就可以看到所有群組裡的頻道。 但也就這樣而已。

沒有權限管理,沒有頻道管理,沒有命名空間管理。 各頻道間還是幾乎獨立,沒辦法一次加入所有頻道, 沒辦法把一人指定為所有頻道的管理員, 也不能統一管理所有頻道的公開、加密程度。 而且沒有命名空間管理,所以例如 +free-rms:matrix.org 群組, 裡面有 press random general 等頻道,如果他們都要有地址, 只能叫 #free-rms-press:matrix.org #free-rms-random:matrix.org #free-rms:matrix.org , 至於為什麼沒有 #free-rms-general:matrix.org , 因為 general 即是通用的頻道,自然可以省去地址裡的 general。

不過這很大程度是因為 community 還是個開發中的功能。 在一開始,這個功能叫 group,現在叫 community。 未來應該會多出一個叫 space 的功能,取代現有的 community, 很有可能,因為現在已經在 beta 了。 只能希望未來這功能會好用一點,像把上面的幾點都改善一下。 而且這功能現在還缺少一些基本的 api,像不能改地址, 也沒有增加除了 creator 外的 admin 的功能。

space 似乎比較神奇,他是一種可以巢狀的頻道, 主要開發的原因是官方發現他們在實作 group 時, 幾乎要把現有的頻道功能重複實現一遍, 像權限管理、邀請之類的,他們覺得很沒意義。 所以他們新增了一種特殊的可以巢狀的頻道, 然後重用頻道現有的權限管理等 api。

discord 與 slack 的概觀

再來是與時下最強群聊軟體沒有之一的 discord 的類比。 以經營組織來說,discord 的確是很強的平台, 當初的 ccns 也是搬到 discord 上後慢慢累積起凝聚力的。

discord 或 slack 都繼承自 irc 的伺服器概念, 一個組織會有一個伺服器,一個伺服器下可以有多個頻道, 同一個伺服器內的管理者是同一群人。 但又和 irc 那種限於頻道內的自治不同, 在不同的 irc 伺服器間,同一個頻道的訊息是會互相轉發的, 而且 irc 管理者也不太會管理到頻道內的事。

在此之外,discord 和 slack 又有一些差別, slack 不同的伺服器間幾乎是完全獨立的, 在每個伺服器都需要重新註冊一遍, 從不同伺服器使用不同的域名就可以大概看出來。 (如 https://g0v.slack.com  https://ccca.slack.com 。) 且不同的伺服器需要在獨立的分頁運作, 不同伺服器的使用者當然也不可能互相溝通。 slack 唯一統一的就是應用程式,或說擴充功能的介面和商店。

而 discord 則較開放。 所謂的伺服器,比較像一個組織, 不知道程式中還是不是對應到一個伺服器。 使用者加入的所有的伺服器,都一樣掛在 discord.com 這個域名下。 (以前是 discordapp.com,後來可能有錢了,把 discord.com 買回來了。) 不同伺服器都有同一個 web 介面中展示, 不同伺服器中的帳號也是統一的,只需要在 discord 註冊一次, 使用者間也可以互相溝通。

slack 可能想製造一種感覺,聊天伺服器的確是由該組織建立的, 是一個內部架設供內部使用的工具,而 slack 只是提供托管伺服器平台。 所以使用者當然需要獨立註冊。 而我猜 discord 則單純認為統一註冊比較簡單而已, 當然可能也考慮了使用者情境。 discord 最早是供遊戲使用的聊天平台, 所以很多人可能只是從某個遊戲得知 discord 這個平台, 上面有一些資訊,所以來到這裡。 然後如果還需要註冊才能進入,那可能就會有一大半放棄了, (相比多數不需登入的 web 上的攻略。) 所以開放了允許訪客的功能。 整體比 slack 要 開放 很多。

discord 與 matrix 的比較

matrix 中有二種功能可以和 discord 的 server 中類比, 一是用實際一部 matrix server,二是用 matrix 中的 community 功能; 但其實二者都不太像。

先前說過,matrix 上允許頻道的自治, 雖然 server admin 能直接把人新增為頻道的 admin, 但頻道內的 admin 還是和 server 的 admin 獨立的。 每個頻道有自己的管理者,最多只能做到同一群人建立的頻道中, 都由同一群人來擔任管理者,但還是需要一一指派。 不像 discord 內,可以直接在 server 中把人劃為不同的階級, 依階級對不同頻道有不同的存取權。

matrix 中的 community 可以用來組織頻道, 但尚不包含統一管理權限的功能。 而且組織事實上是與頻道的管理無關的, 任何人都可以建立組織,將任意的頻道加入自己有管理權限的組織中, 無論在該頻道中是為管理員。 (但如果要建立具有地址的組織, 就得看 server admin 是否開放這項功能,和建立具名頻道一樣。) 所以組織目前比較像是建立了個人用的對該頻道的 捷徑 , 而不是像 discord 中頻道必定隸屬 server 的階層式關係。

而且詳細來看,在 server 下 discord 還有一層 頻道群組 , 其實那層也是關鍵,因為可以把幾個內部頻道放一起設定權限, 而不用一個個單獨設。 其實如果用 matrix server 對比 disocrd server, 那 category 就是類比到 community, 但偏偏 community 又做的比較像 server。 (都是在左側側邊欄的小圈圈。)

如果用命名空間來看,要類比 discord 上的 server, 還是用一座 matrix server 比較適合。 以命名空間來說,某個 server 下的某個頻道, 套在 discord 和 matrix 上都是正確的。 category 和 community 就暫時忽略吧。

matrix 也有把 discord 的 reaction 功能抄過來。 但 bridge 時有二個問題, 一是 bot 沒辦法把 matrix 的 reaction 傳到 discord 上, 因為 discord bot 只有一個,沒辦法傳遞多個 emoji。 而 bridge 則會用建立人頭帳號的方把 discord 上的帳號 bridge 到 matrix, 所以可以用同樣的人頭做出和 discord 上同樣的反應。

另一個比較麻煩的是我們的 discord server 有大量的 costum reaction, matrix 規格現在還不支援。 bridge 會把 costum reaction 的圖片存到 matrix 上, 然後用 mxc 的連結網址當作 reaction name 來對訊息做 react。 但目前 element 還不會把 mxc reaction 顯示成圖片, 就只會看到一串網址。

顯示 custom reaction 的油猴腳本

如上所述,因為我們 discord 伺服器的自訂 reaction 有點多, 加上很多人會用按 reaction 取代傳訊息, 所以有時在 matrix 上看到訊息下有 5、6 個人反應, 但只看 mxc 的 hash 看不出來是在按什麼意思的,就很不方便。

於是就簡單寫了一個 油猴腳本來顯示自訂的反應 , 只要每次 dom 改變時, 就檢查有沒有 reaction 的文字內容是 mxc url, 有的話就把 mxc 換成對應的圖片。

用 mxc 取得圖片的 api 可以在 server-​client 規範裡找到。 監聽 dom 改變的 api, 我是參考另一支 user script google open in new tab。 當初是想改掉 google 搜尋結果中,按右鍵複製網址時, 只會複製到 google 的重導向的網址的問題。 但原本那支太笨重了,我也不需要在新分頁開啟的功能, 就自己改寫了比較 簡短的版本

server client 與 federation 溝通

matrix 是聯邦式的架構,而不是 p2p, 所以和 xmpp 類似,所有資訊都是透過自己的 server 進行, 而不會發生客戶端直接連到對方伺服器的情況。 例如我 @gholk:ccns.io 想傳訊息到 #free-rms:matrix.org 這個頻道, 那我在客戶端操作後,客戶端會把要求傳給我的伺服器, 我的伺服器 ccns.io 會去向 matrix.org 發起連線傳送訊息。

server 端的代理機制

但因為 matrix 有設計 delegation 機制, 可以讓 server 可以用比較好看的網域名稱, 前一篇安裝教學也有稍微提到。 所以要傳訊息給 matrix.org 時, 會先查 matrix.org 這個名字的 matrix 通訊歸誰管:

  1.  _matrix._tcp.matrix.org 這個域名的 dns srv 記錄, 代表要和 matrix.org 以 matrix 協議通訊時,實際負責的是哪個伺服器: host -t SRV _matrix._tcp.matrix.org 

  2. 如果沒有查到,那接著就會嘗試用連到 http://matrix.org/.well-known/matrix/server , 看該 json 檔案有沒有指定 matrix 由哪個伺服器負責。 如果該位址是重導向,則會跟隨重導向。

  3. 如果也沒查到,最後就會直接把該網址當作 matrix server 連線。

所以當你發訊息到 *:matrix.org 時, 依 dns srv 記錄可以看到, 實際上負責 matrix.org 域名的 matrix server 是 matrix-federation.matrix.org.cdn.cloudflare.net 。 而 ccns.io 時,則是 https://ccns.io/.well-known/matrix/server 會轉址到 http://www.ccns.io/.well-known/matrix/server , json 內容即是指定到 matrix.ccns.io:443 {"m.server":"matrix.ccns.io:443"} 

client 端代理機制

上述的傳訊息,是由客戶端發請求給自己的伺服器端, 自己的伺服器端再去找訊息的收件人是在哪個伺服器上。 但客戶端要如何傳送此一請求給自己的伺服器呢? 同樣有類似的代理機制,但又不太一樣。

例如以 @gholk:ccns.io 的身份登入時, 客戶端一樣會去找 ccns.io 這個名稱的伺服器在哪。 但依規範不會檢查 dns,只會檢查 https://ccns.io/.well-known/matrix/client , 因為 matrix 在定規範時考量到想設計出 純前端 client, 所以不會定瀏覽器前端用 javascript 做不到的事。 前端 javascript 不能查 dns,甚至要發 http request 到其它域名, 都需要 cors 標頭,所以 client 代理機制還要求 well known 網址需要有 cors header, 而且轉址的 30X 回覆本身也需要有 cors header。

但因為我們社團 ccns.io 是架在 github page 上, 而 github page 會把 ccns.io 所有網址都用 301 轉址到 www.ccns.io, 且該轉址到目前為止是沒有 cors 的。 所以很明顯,要用客戶端登入 ccns.io 時, 伺服器不能只打 ccns.io,不然客戶端看不到 https://ccns.io/.well-known/matrix/client , 就不會去找 matrix.ccns.io,而會直接把請求發到 ccns.io, 然後就無法登入。 因此要在客戶端登入時,只能直接打 matrix.ccns.io。

這是比較蠢的小事,有關 element ui 設計的問題。 一次點了 ccns community 的三個點的更多符號, 按了 hide 把 community 隱藏了, 然後就不知道怎麼讓該 community 顯示回來。 後來找了很久才找到,要點 community 最下面的 + 新增 community, 然後中間欄的下方會顯示 Your Communities , 把要加回去的 community icon 拖拉到最左側的 community 欄即可。

matrix 金鑰與訊息的儲存

matrix 使用金鑰來驗證身份,client 第一次登入時會產生金鑰, 使用者可以選擇將金鑰備份到 matrix server(在以密碼加密的情況下), 或只存在本機。金鑰以 48 個大小寫英數字混合組成, 如果金鑰沒有備份到伺服器端,則遺失金鑰沒有人能救的了你; 當然遺失密碼也是一樣。

matrix 中所有訊息都存在伺服器端, 加密頻道中訊息是以 e2e 加密的型式儲存的, 所以伺服器存的是加密過的訊息, 伺服器自己也無法解讀,故在搜尋訊息上會不太方便。

另外還有在使用另一台裝置登入時,會需要驗證該登入; 可以用金鑰驗證或用已登入的裝置驗證, 主要就是把金鑰傳到另一台裝置上,讓另一台裝置也能解讀 e2e 訊息。 此步驟可以確保帳號後的使用者是持有同一份金鑰的使用者, 就算伺服器的管理者接管了你的帳號,但他也不會有你的金鑰, 所以其它人也會發現這個人不一樣了。

一些細節可以參考 element 對密碼和金鑰的說明 中 End-​to-​end encryption 的章節。

mx-​puppet-​discord 的權限管理

預設 mx-​puppet-​discord 預設橋接過來後, 是用 discord 的 server id 和 guild id 組成一個很醜的頻道地址, 像原本 discord 上的 general 會變成 #_discordpuppet__330361736932884482:ccns.io ; 然後權限會是只有被邀請者才能加入。 至少要調這二個部份,讓原本在 discord 公開的頻道一樣是公開, 然後有一個好看一點的地址,像原本的 general 就一樣叫 general 就好。

mx-​puppet-​discord 橋接過來後, 預設只有 mx-​puppet-​discord bot 自己是頻道管理者, 但有提供一個叫 adminme #channel_id:server_name 的指令, 讓 bot 把使用者在某個頻道調整為管理者; 但這個指令當然設定橋接的那個使用者能用。 要所以要調整的方式:

  1. 先傳送訊息給 bot adminme #_discordpuppet__330361736932884482:ccns.io , 讓他把使用者調成 admin。

  2. 之後去該頻道的設定頁,新增 local address, 除了原本的一長串 discord id 地址外, 再增加和頻道同名的地址。

  3. 修改設定讓所有人都可以加入。

因為 matrix 不像 discord 可以一次設定整個分類或 server 中所有頻道的權限, 所以用 mx-​puppet-​discord 橋接,自動建立了一堆頻道後, 要一一調整設定其實蠻麻煩的。

由於我們 discord 上有十幾個頻道, 要一個個點顯然不切實際,所以只好自己看 matrix restful api, 用 wget 和 shell 腳本來一次處理完。

使用 shell 腳本批次處理頻道權限

matrix 幾乎所有 api 都是 restful, 可以發 http request 來達成那類修改權限和名稱的操作。 但首先需要是該頻道的 admin, 不知道把頻道 id 一一複製下來再一次貼給 bot 可不可以。 (後來試了似乎是不行。)

取得登入後的 token

這類 api 最簡單的驗證方式是用 token, 也就是用 client 登入後,會取得一個 token, 把 client 端已經取得的 token,直接放在 http header 裡, matrix 就會認得了。 matrix 官方有建議 client 應該要設計一個 ui, 能使用者看到目前使用的 token, 也就能配合其它工具 debug 或 做壞事 管理 server。

以 element 來說,可以點選左上角的使用者帳號選單, 開啟個人設定,在 help 分頁裡有一個展開符號可以顯示 access token, token 應該是英數字和底線組成。 取得之後,把 token 放在要發的 http request Authorization header 裡, 加上一個 bearer 前綴:

wget --header "Authorization: Bearer $token" $request
curl --header "Authorization: Bearer $token" $request

如果在 production 環境上,預設可能只有 wget, wget 有用的選項是 wget -q --server-response , 可以把 http 回應的標頭一起顯示。

matrix api

首先需要取得所有頻道的列表, 當然要先加入所有 bridge 過來的頻道, 以 mx-​puppet-​bridge 來說可以用 joinentireguild 指令 來讓 bot 對自己發出邀請加入所有 bridge 的頻道, 但這個指令只有當初設定 bridge 的人可以用。 之後可以在個人設定裡找到一個選項,是一次同意所有邀請。 這部份可以參見 matrix 安裝中的 bot 連結頻道章節 

之後就能用 matrix api 取得所有頻道列表了,指令如下。 其中 token 是上面取得的登入取得的 token, 返回內容是一個 json。 所以可以先準備個 jq,或直接用 python 或 node js 來解析也可以。

curl --header  "Authorization: Bearer $token"  \
    https://matrix.ccns.io/_matrix/client/r0/joined_rooms

返回 json 的 joined_rooms 屬性是加入的頻道 id 列表, id 是 matrix 在內部用於唯一識別頻道的, 我們還需要取得各頻道的名稱,然後為該頻道加上同名的地址, 與調整權限讓所有使用者可以加入。 更多 api 可以參考 matrix 官方的規格文件 

取得頻道屬性

取得頻道所有屬性的 api 是 state, 但可能會返回太多內容,像每個成員的名稱、icon。

curl -H "Authorization: Bearer $token" \
    https://matrix.ccns.io/_matrix/client/r0/rooms/$id/state

如果不想取回太多東西, 可以在 state 後面加上想取回的事件的類型: https://matrix.ccns.io/_matrix/client/r0/rooms/$id/state/m.room.name , 本文會用到的大概有 m.room.name m.room.guest_access m.room.join_rules m.room.canonical_alias 

增加頻道的地址

我們要先把 room name 取回,然後加為地址,也就是 alias:

json=$(curl -H "Authorization: Bearer $token" \
    https://matrix.ccns.io/_matrix/client/r0/rooms/$id/state/m.room.name)
name=$(node -p "($json).name")
curl -X put -H "Authorization: Bearer $token" \
        https://matrix.ccns.io/_matrix/client/r0/rooms/$id/aliases/$name

把頻道改為任何人可加入

因為用 wget 每次都打一堆選項很麻煩, 所以這裡用一個 alias 把選項塞在一起。 還有因為在 bash 裡打 json 很麻煩, 所以用 node 包裝了一個小工具,可以簡單生成 json; 只要寫單純的 javascript object,就會轉成有雙引號的 json。 如果要比較正式的工具,可以用 jo , 或是可以到 github 用我自己寫的 jc 完整版 

alias www='wget -q --server-response -O -'
jc() {
    node --print "JSON.stringify({$1}, null, '  ')"
}

www --method put --body-data "$(jc "join_rule:'public'")" \
    --header  "Authorization: Bearer $token" \
    https://matrix.ccns.io/_matrix/client/r0/rooms/$id/state/m.room.join_rules/
www --method put --body-data "$(jc 'guest_access:"can_join"')" \
    --header  "Authorization: Bearer $token" \
    http://localhost:8008/_matrix/client/r0/rooms/$id/state/m.room.guest_access/

邀請使用者加入頻道

user 是使用者的完整地址。

# example:
# user=@gholk:ccns.io
curl -H "Authorization: Bearer $token" \
    -H 'content-type: application/json' \
    --data "$(jc "user_id:'$user'")" \
    https://matrix.ccns.io/_matrix/client/r0/rooms/$id/invite

使用者自行要求加入

這 api 只能用來加入公開頻道, 私人頻道一定要有人邀請。

curl -H "Authorization: Bearer $token" \
    -H 'content-type: application/json' \
    --data '{}' \
    https://matrix.ccns.io/_matrix/client/r0/join/$room

有 server admin 權限的情況下

如果有 server 的 admin 權限, 也就在安裝 synapse 時建立的特殊 admin user, 就能用 synapse 的 admin api 直接把人升為 admin。 如果沒有,在有 server 的 root 權限的話也能直接建立一個:

sudo -su matrix-synapse <<MATRIX
cd /opt/venvs/matrix-synapse
. bin/activate
register_new_matrix_user --admin \
    --config /etc/matrix-synapse/homeserver.yaml \
    --user root --password my-password
    https://matrix.ccns.io

MATRIX

之後使用 admin 的流程也是相同, 用 client 登入後,把 token 複製出來用 curl 或 wget 發。 admin 權限能直接把任意使用者升為頻道的 admin。 但這類使用 server admin 的 api, 還沒標準化為 matrix 的一部份,是 synapse 限定的:

wget -O - -q --server-response \
    --post-data "$(jc "user_id:'$user'")" \
    --header  "Authorization: Bearer $token_admin"  \
    https://matrix.ccns.io/_synapse/admin/v1/rooms/$id/make_room_admin

直接取得所有頻道資料

此外,server admin 能直接取得所有頻道的資料, 但 matrix.ccns 上的 _synapse 路徑我是鎖起來的, 不能從外網存取,所以得直接登入 matrix.ccns 上, 然後找找 localhost。

wget -O - -q --server-response \
    --header  "Authorization: Bearer $token_admin"  \
    http://localhost:8008/_synapse/admin/v1/rooms

新增頻道管理員

該使用者需要在頻道內:

www --post-data "$(jc "user_id:'$user'")" \
    --header "Authorization: Bearer $token_root" \
    "http://localhost:8008/_synapse/admin/v1/rooms/$id/make_room_admin"

讓使用者在註冊後自動加入頻道

修改 /etc/matrix-synapse/homeserver.yaml , 我是加在 /etc/matrix-synapse/auto_join_rooms.yaml , 以 ccns 來說預設有以下頻道, 這樣註冊後就會自動加入這些頻道了。

auto_join_rooms:
  - "#acgn:ccns.io"
  - "#ani-platform:ccns.io"
  - "#bbs-dev:ccns.io"
  - "#bbs-dev-notify:ccns.io"
  - "#blockchain:ccns.io"
  - "#book:ccns.io"
  - "#bot-commands:ccns.io"
  - "#bullshit:ccns.io"
  - "#channel-request:ccns.io"
  - "#computer-system:ccns.io"
  - "#cryptocurrency:ccns.io"
  - "#emoji-request:ccns.io"
  - "#food:ccns.io"
  - "#game-design:ccns.io"
  - "#general:ccns.io"
  - "#general-english:ccns.io"
  - "#git:ccns.io"
  - "#hydra-command:ccns.io"
  - "#hydra-song-requests:ccns.io"
  - "#machine-learning:ccns.io"
  - "#math:ccns.io"
  - "#minecraft-chat:ccns.io"
  - "#music:ccns.io"
  - "#programming-contest:ccns.io"
  - "#radio:ccns.io"
  - "#robotics:ccns.io"
  - "#security:ccns.io"
  - "#service-status:ccns.io"
  - "#software-develop:ccns.io"
  - "#stonk:ccns.io"
  - "#study-group:ccns.io"
  - "#sysadmin:ccns.io"
  - "#vim:ccns.io"
  - "#vscode:ccns.io"
  - "#vtuber:ccns.io"
  - "#vtuber-notify:ccns.io"
  - "#web-develop:ccns.io"
  - "#welcome:ccns.io"
  - "#announcement:ccns.io"

转自: gholk.github.io/ccns-matrix-luser-review.html

4 条评论:

gholk 说...

為什麼全文轉載我的文章?請問此 blog 的站長是誰?雖然你發表了很多網路自由相關文章,但轉載且不註明出處是不禮貌的行為。請注明此文為轉載自 http://gholk.github.io/ccns-matrix-luser-review.html

匿名 说...

不是在最后?还是你另有协议?

gholk 说...

勉強還行。原本你只留一行網址誰知道什麼意思?現在至少有一個「轉自」了。
建議你在網站首頁留個本站所有內容皆為轉載之類的,不然還要整篇讀完才看到「轉自」二個字。

匿名 说...

需不需要加大字号呢? (thinking)