7 Commits

Author SHA1 Message Date
5122e9cd87 Bump ToC font size to match body text
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 08:10:53 -07:00
ed9291a7ad Add sticky ToC sidebar to document read view
Builds a table of contents client-side from rendered heading IDs.
Highlights the current section on scroll. Collapses to inline on
narrow screens.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 23:59:14 -07:00
7ab00fc518 Remove docs file — lives on mcq.metacircular.net instead 2001-01-01 00:00:00 +00:00
051abae390 Add platform packaging and deployment guide
Synced from metacircular/docs with SSO login documentation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 23:50:10 -07:00
dd5142a48a Fix template error: pass CSRF func on SSO login page
Go templates require all referenced functions to be defined at parse
time, even in branches that won't execute.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 23:35:21 -07:00
2c3db6ea25 Add SSO login support via mcdsl/sso
When [sso].redirect_uri is configured, the web UI shows a "Sign in
with MCIAS" button instead of the username/password form. Upgrades
mcdsl to v1.7.0 which includes the Firefox cookie fix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 23:15:06 -07:00
063bdccf1b Second deployment test. 2026-03-29 18:13:15 -07:00
8 changed files with 229 additions and 45 deletions

View File

@@ -77,6 +77,9 @@ func runServer(configPath string) error {
wsCfg := webserver.Config{
ServiceName: cfg.MCIAS.ServiceName,
Tags: cfg.MCIAS.Tags,
MciasURL: cfg.MCIAS.ServerURL,
CACert: cfg.MCIAS.CACert,
RedirectURI: cfg.SSO.RedirectURI,
}
webSrv, err := webserver.New(wsCfg, database, authClient, logger)
if err != nil {

2
go.mod
View File

@@ -3,7 +3,7 @@ module git.wntrmute.dev/mc/mcq
go 1.25.7
require (
git.wntrmute.dev/mc/mcdsl v1.2.0
git.wntrmute.dev/mc/mcdsl v1.7.0
github.com/alecthomas/chroma/v2 v2.18.0
github.com/go-chi/chi/v5 v5.2.5
github.com/mark3labs/mcp-go v0.46.0

4
go.sum
View File

@@ -1,5 +1,5 @@
git.wntrmute.dev/mc/mcdsl v1.2.0 h1:41hep7/PNZJfN0SN/nM+rQpyF1GSZcvNNjyVG81DI7U=
git.wntrmute.dev/mc/mcdsl v1.2.0/go.mod h1:lXYrAt74ZUix6rx9oVN8d2zH1YJoyp4uxPVKQ+SSxuM=
git.wntrmute.dev/mc/mcdsl v1.7.0 h1:dAh2SGdzjhz0H66i3KAMDm1eRYYgMaxqQ0Pj5NzF7fc=
git.wntrmute.dev/mc/mcdsl v1.7.0/go.mod h1:MhYahIu7Sg53lE2zpQ20nlrsoNRjQzOJBAlCmom2wJc=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=

View File

@@ -17,9 +17,17 @@ type Config struct {
Server ServerConfig `toml:"server"`
Database DatabaseConfig `toml:"database"`
MCIAS mcdslauth.Config `toml:"mcias"`
SSO SSOConfig `toml:"sso"`
Log LogConfig `toml:"log"`
}
// SSOConfig holds SSO redirect settings for the web UI.
type SSOConfig struct {
// RedirectURI is the callback URL that MCIAS redirects to after login.
// Must exactly match the redirect_uri registered in MCIAS config.
RedirectURI string `toml:"redirect_uri"`
}
// ServerConfig holds HTTP/gRPC server settings. TLS fields are optional;
// when empty, MCQ serves plain HTTP (for use behind mc-proxy L7).
type ServerConfig struct {

View File

@@ -11,6 +11,7 @@ import (
"git.wntrmute.dev/mc/mcdsl/auth"
"git.wntrmute.dev/mc/mcdsl/csrf"
mcdsso "git.wntrmute.dev/mc/mcdsl/sso"
"git.wntrmute.dev/mc/mcdsl/web"
mcqweb "git.wntrmute.dev/mc/mcq/web"
@@ -25,6 +26,11 @@ const cookieName = "mcq_session"
type Config struct {
ServiceName string
Tags []string
// SSO fields — when RedirectURI is non-empty, the web UI uses SSO instead
// of the direct username/password login form.
MciasURL string
CACert string
RedirectURI string
}
// Server is the MCQ web UI server.
@@ -32,6 +38,7 @@ type Server struct {
db *db.DB
auth *auth.Authenticator
csrf *csrf.Protect
ssoClient *mcdsso.Client
render *render.Renderer
logger *slog.Logger
config Config
@@ -45,20 +52,43 @@ func New(cfg Config, database *db.DB, authenticator *auth.Authenticator, logger
}
csrfProtect := csrf.New(csrfSecret, "_csrf", "csrf_token")
return &Server{
s := &Server{
db: database,
auth: authenticator,
csrf: csrfProtect,
render: render.New(),
logger: logger,
config: cfg,
}, nil
}
// Create SSO client if the service has an SSO redirect_uri configured.
if cfg.RedirectURI != "" {
ssoClient, err := mcdsso.New(mcdsso.Config{
MciasURL: cfg.MciasURL,
ClientID: "mcq",
RedirectURI: cfg.RedirectURI,
CACert: cfg.CACert,
})
if err != nil {
return nil, fmt.Errorf("create SSO client: %w", err)
}
s.ssoClient = ssoClient
logger.Info("SSO enabled: redirecting to MCIAS for login", "mcias_url", cfg.MciasURL)
}
return s, nil
}
// RegisterRoutes adds web UI routes to the given router.
func (s *Server) RegisterRoutes(r chi.Router) {
if s.ssoClient != nil {
r.Get("/login", s.handleSSOLogin)
r.Get("/sso/redirect", s.handleSSORedirect)
r.Get("/sso/callback", s.handleSSOCallback)
} else {
r.Get("/login", s.handleLoginPage)
r.Post("/login", s.csrf.Middleware(http.HandlerFunc(s.handleLogin)).ServeHTTP)
}
r.Get("/static/*", http.FileServer(http.FS(mcqweb.FS)).ServeHTTP)
// Authenticated routes.
@@ -80,6 +110,7 @@ type pageData struct {
Error string
Title string
Content any
SSO bool
}
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
@@ -101,6 +132,32 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusFound)
}
// handleSSOLogin renders a landing page with a "Sign in with MCIAS" button.
func (s *Server) handleSSOLogin(w http.ResponseWriter, r *http.Request) {
web.RenderTemplate(w, mcqweb.FS, "login.html", pageData{SSO: true}, s.csrf.TemplateFunc(w))
}
// handleSSORedirect initiates the SSO redirect to MCIAS.
func (s *Server) handleSSORedirect(w http.ResponseWriter, r *http.Request) {
if err := mcdsso.RedirectToLogin(w, r, s.ssoClient, "mcq"); err != nil {
s.logger.Error("sso: redirect to login", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
}
}
// handleSSOCallback exchanges the authorization code for a JWT and sets the session.
func (s *Server) handleSSOCallback(w http.ResponseWriter, r *http.Request) {
token, returnTo, err := mcdsso.HandleCallback(w, r, s.ssoClient, "mcq")
if err != nil {
s.logger.Error("sso: callback", "error", err)
http.Error(w, "Login failed. Please try again.", http.StatusUnauthorized)
return
}
web.SetSessionCookie(w, cookieName, token)
http.Redirect(w, r, returnTo, http.StatusSeeOther)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
token := web.GetSessionToken(r, cookieName)
if token != "" {

View File

@@ -353,8 +353,75 @@ button:hover, .btn:hover {
}
/* ===========================
Read view
Read view — two-column layout
=========================== */
.read-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 2rem 2rem 0;
}
.read-layout {
display: grid;
grid-template-columns: 220px 1fr;
gap: 2rem;
align-items: start;
}
.read-main {
max-width: 900px;
min-width: 0;
}
.read-toc {
position: sticky;
top: 1rem;
max-height: calc(100vh - 2rem);
overflow-y: auto;
padding: 0.5rem 0;
font-size: 0.875rem;
line-height: 1.5;
border-right: 1px solid var(--border-lt);
padding-right: 1rem;
}
.read-toc ul {
list-style: none;
padding: 0;
margin: 0;
}
.read-toc li {
margin-bottom: 0.25rem;
}
.read-toc a {
color: var(--text-lt);
text-decoration: none;
display: block;
padding: 0.125rem 0;
transition: color 0.1s;
}
.read-toc a:hover {
color: var(--text);
text-decoration: none;
}
.read-toc a.toc-active {
color: var(--accent);
}
@media (max-width: 900px) {
.read-container {
padding: 1.5rem 1rem;
}
.read-layout {
grid-template-columns: 1fr;
}
.read-toc {
position: static;
max-height: none;
border-right: none;
border-bottom: 1px solid var(--border-lt);
padding-right: 0;
padding-bottom: 0.75rem;
margin-bottom: 0.5rem;
}
}
.read-header {
margin-bottom: 1.5rem;
}

View File

@@ -3,10 +3,15 @@
{{define "content"}}
<div class="auth-header">
<div class="brand">mcq</div>
<div class="tagline">Reading Queue</div>
<div class="tagline">Metacircular Reading Queue</div>
</div>
<div class="card">
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
{{if .SSO}}
<div class="form-actions">
<a href="/sso/redirect" style="display:block;text-align:center;text-decoration:none;"><button type="button" style="width:100%" class="btn">Sign in with MCIAS</button></a>
</div>
{{else}}
<form method="POST" action="/login">
{{csrfField}}
<div class="form-group">
@@ -25,5 +30,6 @@
<button type="submit" class="btn">Login</button>
</div>
</form>
{{end}}
</div>
{{end}}

View File

@@ -1,6 +1,10 @@
{{define "title"}} — {{.Doc.Title}}{{end}}
{{define "container-class"}}read-container{{end}}
{{define "content"}}
<div class="read-header">
<div class="read-layout">
<nav class="read-toc" id="toc" aria-label="Table of contents"></nav>
<div class="read-main">
<div class="read-header">
<h2>{{.Doc.Title}}</h2>
<div class="read-meta">
<span>Pushed by {{.Doc.PushedBy}}</span>
@@ -25,8 +29,47 @@
</form>
<a href="/" class="btn-ghost btn btn-sm">Back to queue</a>
</div>
</div>
<div class="card markdown-body">
</div>
<div class="card markdown-body" id="article">
{{.HTML}}
</div>
</div>
</div>
<script>
(function(){
var toc=document.getElementById("toc");
var headings=document.querySelectorAll("#article h1, #article h2, #article h3");
if(headings.length<2){toc.style.display="none";return;}
var ul=document.createElement("ul");
var minLevel=6;
headings.forEach(function(h){var l=parseInt(h.tagName[1]);if(l<minLevel)minLevel=l;});
headings.forEach(function(h){
if(!h.id)return;
var li=document.createElement("li");
var a=document.createElement("a");
a.href="#"+h.id;
a.textContent=h.textContent;
var depth=parseInt(h.tagName[1])-minLevel;
li.style.paddingLeft=(depth*0.75)+"rem";
li.appendChild(a);
ul.appendChild(li);
});
toc.appendChild(ul);
/* highlight current section on scroll */
var links=toc.querySelectorAll("a");
var ids=[];links.forEach(function(a){ids.push(a.getAttribute("href").slice(1));});
function onScroll(){
var current="";
for(var i=0;i<ids.length;i++){
var el=document.getElementById(ids[i]);
if(el&&el.getBoundingClientRect().top<=80)current=ids[i];
}
links.forEach(function(a){
a.classList.toggle("toc-active",a.getAttribute("href")==="#"+current);
});
}
window.addEventListener("scroll",onScroll,{passive:true});
onScroll();
})();
</script>
{{end}}