Initial implementation of mcq — document reading queue

Single-binary service: push raw markdown via REST/gRPC API, read rendered
HTML through mobile-friendly web UI. MCIAS auth on all endpoints, SQLite
storage, goldmark rendering with GFM and syntax highlighting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-28 11:53:26 -07:00
commit bc1627915e
36 changed files with 3773 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/mcq
/srv/

90
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,90 @@
# MCQ Architecture
## Purpose
MCQ is a document reading queue. Push raw markdown from inside the
infrastructure, read rendered HTML on any device via the web UI.
## System Context
```
Push clients (curl, scripts, Claude remote)
▼ PUT /v1/documents/{slug}
┌─────────┐ ┌──────────┐
│ MCQ │────▶│ MCIAS │ auth validation
│ :8443 │◀────│ :8443 │
└─────────┘ └──────────┘
▼ SQLite
┌─────────┐
│ mcq.db │
└─────────┘
Browser (phone, desktop)
▼ GET / → login → reading queue → /d/{slug}
┌─────────┐
│ MCQ │
│ web UI │
└─────────┘
```
## Data Model
Single table:
```sql
CREATE TABLE documents (
id INTEGER PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
body TEXT NOT NULL, -- raw markdown
pushed_by TEXT NOT NULL, -- MCIAS username
pushed_at TEXT NOT NULL, -- RFC 3339 UTC
read INTEGER NOT NULL DEFAULT 0
);
```
Slug is the identity key. PUT with the same slug replaces content and
resets the read flag.
## API
### REST (Bearer token auth)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | /v1/auth/login | Public | Get bearer token |
| POST | /v1/auth/logout | Auth | Revoke token |
| GET | /v1/health | Public | Health check |
| GET | /v1/documents | Auth | List all documents |
| GET | /v1/documents/{slug} | Auth | Get document |
| PUT | /v1/documents/{slug} | Auth | Create or update |
| DELETE | /v1/documents/{slug} | Auth | Remove document |
| POST | /v1/documents/{slug}/read | Auth | Mark read |
| POST | /v1/documents/{slug}/unread | Auth | Mark unread |
### gRPC
DocumentService, AuthService, AdminService — mirrors REST exactly.
### Web UI (session cookie auth)
| Path | Description |
|------|-------------|
| /login | MCIAS login form |
| / | Document list (queue) |
| /d/{slug} | Rendered markdown reader |
## Security
- MCIAS auth on all endpoints (REST: Bearer, Web: session cookie, gRPC: interceptor)
- CSRF double-submit cookies on all web mutations
- TLS 1.3 minimum
- Default-deny on unmapped gRPC methods
## Rendering
Goldmark with GFM extensions, Chroma syntax highlighting, auto heading IDs.
Markdown stored raw in SQLite, rendered to HTML on each page view.

69
CLAUDE.md Normal file
View File

@@ -0,0 +1,69 @@
# CLAUDE.md
## Overview
MCQ (Metacircular Document Queue) is a reading queue service. Documents
(raw markdown) are pushed via API from inside the infrastructure, then
read through a mobile-friendly web UI from anywhere. MCIAS authenticates
all access — any user (including guest) can read, any user (including
system accounts) can push.
## Build Commands
```bash
make all # vet → lint → test → build
make mcq # build binary with version injection
make build # go build ./...
make test # go test ./...
make vet # go vet ./...
make lint # golangci-lint run ./...
make proto # regenerate gRPC code from .proto files
make proto-lint # buf lint + buf breaking
make devserver # build and run locally against srv/mcq.toml
make clean # remove binaries
```
Run a single test:
```bash
go test ./internal/db/ -run TestPutDocument
```
## Architecture
Single binary, three concerns:
- **REST API** (`/v1/*`) — CRUD for documents, MCIAS Bearer token auth
- **gRPC API** (`:9443`) — same operations, MCIAS interceptor auth
- **Web UI** (`/`, `/d/{slug}`, `/login`) — goldmark-rendered reader, MCIAS session cookies
Documents keyed by slug (unique). PUT upserts — same slug replaces content.
## Project Structure
```
cmd/mcq/ CLI entry point (server subcommand)
internal/
db/ SQLite schema, migrations, document CRUD
server/ REST API routes and handlers
grpcserver/ gRPC server, interceptors, service handlers
webserver/ Web UI routes, templates, session management
render/ goldmark markdown-to-HTML renderer
proto/mcq/v1/ Protobuf definitions
gen/mcq/v1/ Generated Go code (do not edit)
web/ Embedded templates + static files
deploy/ systemd, examples
```
## Shared Library
MCQ uses `mcdsl` (git.wntrmute.dev/mc/mcdsl) for: auth, db, config,
httpserver, grpcserver, csrf, web (session cookies, auth middleware,
template rendering).
## Critical Rules
1. **REST/gRPC sync**: Every REST endpoint has a corresponding gRPC RPC.
2. **gRPC interceptor maps**: New RPCs must be added to the correct map.
3. **No test frameworks**: stdlib `testing` only, real SQLite in t.TempDir().
4. **CSRF on all web mutations**: double-submit cookie pattern.
5. **Session cookies**: HttpOnly, Secure, SameSite=Strict.

45
Makefile Normal file
View File

@@ -0,0 +1,45 @@
.PHONY: build test vet lint proto proto-lint clean docker push all devserver
MCR := mcr.svc.mcp.metacircular.net:8443
VERSION := $(shell git describe --tags --always --dirty)
LDFLAGS := -trimpath -ldflags="-s -w -X main.version=$(VERSION)"
mcq:
CGO_ENABLED=0 go build $(LDFLAGS) -o mcq ./cmd/mcq
build:
go build ./...
test:
go test ./...
vet:
go vet ./...
lint:
golangci-lint run ./...
proto:
protoc --go_out=. --go_opt=module=git.wntrmute.dev/mc/mcq \
--go-grpc_out=. --go-grpc_opt=module=git.wntrmute.dev/mc/mcq \
proto/mcq/v1/*.proto
proto-lint:
buf lint
buf breaking --against '.git#branch=master,subdir=proto'
clean:
rm -f mcq
docker:
docker build --build-arg VERSION=$(VERSION) -t $(MCR)/mcq:$(VERSION) -f Dockerfile .
push: docker
docker push $(MCR)/mcq:$(VERSION)
devserver: mcq
@mkdir -p srv
@if [ ! -f srv/mcq.toml ]; then cp deploy/examples/mcq.toml.example srv/mcq.toml; echo "Created srv/mcq.toml from example — edit before running."; fi
./mcq server --config srv/mcq.toml
all: vet lint test mcq

9
buf.yaml Normal file
View File

@@ -0,0 +1,9 @@
version: v2
modules:
- path: proto
lint:
use:
- STANDARD
breaking:
use:
- FILE

23
cmd/mcq/main.go Normal file
View File

@@ -0,0 +1,23 @@
package main
import (
"os"
"github.com/spf13/cobra"
)
var version = "dev"
func main() {
root := &cobra.Command{
Use: "mcq",
Short: "Metacircular Document Queue",
Version: version,
}
root.AddCommand(serverCmd())
if err := root.Execute(); err != nil {
os.Exit(1)
}
}

168
cmd/mcq/server.go Normal file
View File

@@ -0,0 +1,168 @@
package main
import (
"context"
"fmt"
"log/slog"
"net"
"os"
"os/signal"
"syscall"
"time"
"github.com/spf13/cobra"
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
"git.wntrmute.dev/mc/mcdsl/config"
"git.wntrmute.dev/mc/mcdsl/httpserver"
"git.wntrmute.dev/mc/mcq/internal/db"
"git.wntrmute.dev/mc/mcq/internal/grpcserver"
"git.wntrmute.dev/mc/mcq/internal/server"
"git.wntrmute.dev/mc/mcq/internal/webserver"
)
type mcqConfig struct {
config.Base
}
func serverCmd() *cobra.Command {
var configPath string
cmd := &cobra.Command{
Use: "server",
Short: "Start the MCQ server",
RunE: func(_ *cobra.Command, _ []string) error {
return runServer(configPath)
},
}
cmd.Flags().StringVarP(&configPath, "config", "c", "mcq.toml", "path to config file")
return cmd
}
func runServer(configPath string) error {
cfg, err := config.Load[mcqConfig](configPath, "MCQ")
if err != nil {
return fmt.Errorf("load config: %w", err)
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: parseLogLevel(cfg.Log.Level),
}))
// Open and migrate the database.
database, err := db.Open(cfg.Database.Path)
if err != nil {
return fmt.Errorf("open database: %w", err)
}
defer database.Close()
if err := database.Migrate(); err != nil {
return fmt.Errorf("migrate database: %w", err)
}
// Create auth client for MCIAS integration.
authClient, err := mcdslauth.New(cfg.MCIAS, logger)
if err != nil {
return fmt.Errorf("create auth client: %w", err)
}
// HTTP server — all routes on one router.
httpSrv := httpserver.New(cfg.Server, logger)
httpSrv.Router.Use(httpSrv.LoggingMiddleware)
// Register REST API routes (/v1/*).
server.RegisterRoutes(httpSrv.Router, server.Deps{
DB: database,
Auth: authClient,
Logger: logger,
})
// Register web UI routes (/, /login, /d/*).
wsCfg := webserver.Config{
ServiceName: cfg.MCIAS.ServiceName,
Tags: cfg.MCIAS.Tags,
}
webSrv, err := webserver.New(wsCfg, database, authClient, logger)
if err != nil {
return fmt.Errorf("create web server: %w", err)
}
webSrv.RegisterRoutes(httpSrv.Router)
// Start gRPC server if configured.
var grpcSrv *grpcserver.Server
var grpcLis net.Listener
if cfg.Server.GRPCAddr != "" {
grpcSrv, err = grpcserver.New(cfg.Server.TLSCert, cfg.Server.TLSKey, grpcserver.Deps{
DB: database,
Authenticator: authClient,
}, logger)
if err != nil {
return fmt.Errorf("create gRPC server: %w", err)
}
grpcLis, err = net.Listen("tcp", cfg.Server.GRPCAddr)
if err != nil {
return fmt.Errorf("listen gRPC on %s: %w", cfg.Server.GRPCAddr, err)
}
}
// Graceful shutdown.
grpcServeStarted := false
shutdownAll := func() {
if grpcSrv != nil {
grpcSrv.GracefulStop()
} else if grpcLis != nil && !grpcServeStarted {
_ = grpcLis.Close()
}
shutdownTimeout := 30 * time.Second
if cfg.Server.ShutdownTimeout.Duration > 0 {
shutdownTimeout = cfg.Server.ShutdownTimeout.Duration
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
_ = httpSrv.Shutdown(shutdownCtx)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 2)
if grpcSrv != nil {
grpcServeStarted = true
go func() {
logger.Info("gRPC server listening", "addr", grpcLis.Addr())
errCh <- grpcSrv.Serve(grpcLis)
}()
}
go func() {
logger.Info("mcq starting", "version", version, "addr", cfg.Server.ListenAddr)
errCh <- httpSrv.ListenAndServeTLS()
}()
select {
case err := <-errCh:
shutdownAll()
return fmt.Errorf("server error: %w", err)
case <-ctx.Done():
logger.Info("shutting down")
shutdownAll()
logger.Info("mcq stopped")
return nil
}
}
func parseLogLevel(s string) slog.Level {
switch s {
case "debug":
return slog.LevelDebug
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}

View File

@@ -0,0 +1,16 @@
[server]
listen_addr = ":8443"
grpc_addr = ":9443"
tls_cert = "srv/cert.pem"
tls_key = "srv/key.pem"
[database]
path = "srv/mcq.db"
[mcias]
server_url = "https://mcias.svc.metacircular.net:8443"
service_name = "mcq"
tags = []
[log]
level = "info"

View File

@@ -0,0 +1,25 @@
[Unit]
Description=MCQ Document Queue
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/mcq server --config /srv/mcq/mcq.toml
WorkingDirectory=/srv/mcq
Restart=on-failure
RestartSec=5
User=mcq
Group=mcq
# Security hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/srv/mcq
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectControlGroups=yes
[Install]
WantedBy=multi-user.target

869
gen/mcq/v1/mcq.pb.go Normal file
View File

@@ -0,0 +1,869 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v6.32.1
// source: proto/mcq/v1/mcq.proto
package mcqv1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type Document struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
Slug string `protobuf:"bytes,2,opt,name=slug,proto3" json:"slug,omitempty"`
Title string `protobuf:"bytes,3,opt,name=title,proto3" json:"title,omitempty"`
Body string `protobuf:"bytes,4,opt,name=body,proto3" json:"body,omitempty"`
PushedBy string `protobuf:"bytes,5,opt,name=pushed_by,json=pushedBy,proto3" json:"pushed_by,omitempty"`
PushedAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=pushed_at,json=pushedAt,proto3" json:"pushed_at,omitempty"`
Read bool `protobuf:"varint,7,opt,name=read,proto3" json:"read,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Document) Reset() {
*x = Document{}
mi := &file_proto_mcq_v1_mcq_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Document) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Document) ProtoMessage() {}
func (x *Document) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcq_v1_mcq_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Document.ProtoReflect.Descriptor instead.
func (*Document) Descriptor() ([]byte, []int) {
return file_proto_mcq_v1_mcq_proto_rawDescGZIP(), []int{0}
}
func (x *Document) GetId() int64 {
if x != nil {
return x.Id
}
return 0
}
func (x *Document) GetSlug() string {
if x != nil {
return x.Slug
}
return ""
}
func (x *Document) GetTitle() string {
if x != nil {
return x.Title
}
return ""
}
func (x *Document) GetBody() string {
if x != nil {
return x.Body
}
return ""
}
func (x *Document) GetPushedBy() string {
if x != nil {
return x.PushedBy
}
return ""
}
func (x *Document) GetPushedAt() *timestamppb.Timestamp {
if x != nil {
return x.PushedAt
}
return nil
}
func (x *Document) GetRead() bool {
if x != nil {
return x.Read
}
return false
}
type ListDocumentsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListDocumentsRequest) Reset() {
*x = ListDocumentsRequest{}
mi := &file_proto_mcq_v1_mcq_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListDocumentsRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListDocumentsRequest) ProtoMessage() {}
func (x *ListDocumentsRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcq_v1_mcq_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListDocumentsRequest.ProtoReflect.Descriptor instead.
func (*ListDocumentsRequest) Descriptor() ([]byte, []int) {
return file_proto_mcq_v1_mcq_proto_rawDescGZIP(), []int{1}
}
type ListDocumentsResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Documents []*Document `protobuf:"bytes,1,rep,name=documents,proto3" json:"documents,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListDocumentsResponse) Reset() {
*x = ListDocumentsResponse{}
mi := &file_proto_mcq_v1_mcq_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListDocumentsResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListDocumentsResponse) ProtoMessage() {}
func (x *ListDocumentsResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcq_v1_mcq_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListDocumentsResponse.ProtoReflect.Descriptor instead.
func (*ListDocumentsResponse) Descriptor() ([]byte, []int) {
return file_proto_mcq_v1_mcq_proto_rawDescGZIP(), []int{2}
}
func (x *ListDocumentsResponse) GetDocuments() []*Document {
if x != nil {
return x.Documents
}
return nil
}
type GetDocumentRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Slug string `protobuf:"bytes,1,opt,name=slug,proto3" json:"slug,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetDocumentRequest) Reset() {
*x = GetDocumentRequest{}
mi := &file_proto_mcq_v1_mcq_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetDocumentRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetDocumentRequest) ProtoMessage() {}
func (x *GetDocumentRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcq_v1_mcq_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetDocumentRequest.ProtoReflect.Descriptor instead.
func (*GetDocumentRequest) Descriptor() ([]byte, []int) {
return file_proto_mcq_v1_mcq_proto_rawDescGZIP(), []int{3}
}
func (x *GetDocumentRequest) GetSlug() string {
if x != nil {
return x.Slug
}
return ""
}
type PutDocumentRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Slug string `protobuf:"bytes,1,opt,name=slug,proto3" json:"slug,omitempty"`
Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"`
Body string `protobuf:"bytes,3,opt,name=body,proto3" json:"body,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PutDocumentRequest) Reset() {
*x = PutDocumentRequest{}
mi := &file_proto_mcq_v1_mcq_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PutDocumentRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PutDocumentRequest) ProtoMessage() {}
func (x *PutDocumentRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcq_v1_mcq_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PutDocumentRequest.ProtoReflect.Descriptor instead.
func (*PutDocumentRequest) Descriptor() ([]byte, []int) {
return file_proto_mcq_v1_mcq_proto_rawDescGZIP(), []int{4}
}
func (x *PutDocumentRequest) GetSlug() string {
if x != nil {
return x.Slug
}
return ""
}
func (x *PutDocumentRequest) GetTitle() string {
if x != nil {
return x.Title
}
return ""
}
func (x *PutDocumentRequest) GetBody() string {
if x != nil {
return x.Body
}
return ""
}
type DeleteDocumentRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Slug string `protobuf:"bytes,1,opt,name=slug,proto3" json:"slug,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DeleteDocumentRequest) Reset() {
*x = DeleteDocumentRequest{}
mi := &file_proto_mcq_v1_mcq_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DeleteDocumentRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DeleteDocumentRequest) ProtoMessage() {}
func (x *DeleteDocumentRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcq_v1_mcq_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DeleteDocumentRequest.ProtoReflect.Descriptor instead.
func (*DeleteDocumentRequest) Descriptor() ([]byte, []int) {
return file_proto_mcq_v1_mcq_proto_rawDescGZIP(), []int{5}
}
func (x *DeleteDocumentRequest) GetSlug() string {
if x != nil {
return x.Slug
}
return ""
}
type DeleteDocumentResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DeleteDocumentResponse) Reset() {
*x = DeleteDocumentResponse{}
mi := &file_proto_mcq_v1_mcq_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DeleteDocumentResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DeleteDocumentResponse) ProtoMessage() {}
func (x *DeleteDocumentResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcq_v1_mcq_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DeleteDocumentResponse.ProtoReflect.Descriptor instead.
func (*DeleteDocumentResponse) Descriptor() ([]byte, []int) {
return file_proto_mcq_v1_mcq_proto_rawDescGZIP(), []int{6}
}
type MarkReadRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Slug string `protobuf:"bytes,1,opt,name=slug,proto3" json:"slug,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *MarkReadRequest) Reset() {
*x = MarkReadRequest{}
mi := &file_proto_mcq_v1_mcq_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *MarkReadRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*MarkReadRequest) ProtoMessage() {}
func (x *MarkReadRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcq_v1_mcq_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use MarkReadRequest.ProtoReflect.Descriptor instead.
func (*MarkReadRequest) Descriptor() ([]byte, []int) {
return file_proto_mcq_v1_mcq_proto_rawDescGZIP(), []int{7}
}
func (x *MarkReadRequest) GetSlug() string {
if x != nil {
return x.Slug
}
return ""
}
type MarkUnreadRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Slug string `protobuf:"bytes,1,opt,name=slug,proto3" json:"slug,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *MarkUnreadRequest) Reset() {
*x = MarkUnreadRequest{}
mi := &file_proto_mcq_v1_mcq_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *MarkUnreadRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*MarkUnreadRequest) ProtoMessage() {}
func (x *MarkUnreadRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcq_v1_mcq_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use MarkUnreadRequest.ProtoReflect.Descriptor instead.
func (*MarkUnreadRequest) Descriptor() ([]byte, []int) {
return file_proto_mcq_v1_mcq_proto_rawDescGZIP(), []int{8}
}
func (x *MarkUnreadRequest) GetSlug() string {
if x != nil {
return x.Slug
}
return ""
}
type LoginRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"`
Password string `protobuf:"bytes,2,opt,name=password,proto3" json:"password,omitempty"` // security: never logged
TotpCode string `protobuf:"bytes,3,opt,name=totp_code,json=totpCode,proto3" json:"totp_code,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *LoginRequest) Reset() {
*x = LoginRequest{}
mi := &file_proto_mcq_v1_mcq_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *LoginRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*LoginRequest) ProtoMessage() {}
func (x *LoginRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcq_v1_mcq_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use LoginRequest.ProtoReflect.Descriptor instead.
func (*LoginRequest) Descriptor() ([]byte, []int) {
return file_proto_mcq_v1_mcq_proto_rawDescGZIP(), []int{9}
}
func (x *LoginRequest) GetUsername() string {
if x != nil {
return x.Username
}
return ""
}
func (x *LoginRequest) GetPassword() string {
if x != nil {
return x.Password
}
return ""
}
func (x *LoginRequest) GetTotpCode() string {
if x != nil {
return x.TotpCode
}
return ""
}
type LoginResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"` // security: never logged
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *LoginResponse) Reset() {
*x = LoginResponse{}
mi := &file_proto_mcq_v1_mcq_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *LoginResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*LoginResponse) ProtoMessage() {}
func (x *LoginResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcq_v1_mcq_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use LoginResponse.ProtoReflect.Descriptor instead.
func (*LoginResponse) Descriptor() ([]byte, []int) {
return file_proto_mcq_v1_mcq_proto_rawDescGZIP(), []int{10}
}
func (x *LoginResponse) GetToken() string {
if x != nil {
return x.Token
}
return ""
}
type LogoutRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"` // security: never logged
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *LogoutRequest) Reset() {
*x = LogoutRequest{}
mi := &file_proto_mcq_v1_mcq_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *LogoutRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*LogoutRequest) ProtoMessage() {}
func (x *LogoutRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcq_v1_mcq_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use LogoutRequest.ProtoReflect.Descriptor instead.
func (*LogoutRequest) Descriptor() ([]byte, []int) {
return file_proto_mcq_v1_mcq_proto_rawDescGZIP(), []int{11}
}
func (x *LogoutRequest) GetToken() string {
if x != nil {
return x.Token
}
return ""
}
type LogoutResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *LogoutResponse) Reset() {
*x = LogoutResponse{}
mi := &file_proto_mcq_v1_mcq_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *LogoutResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*LogoutResponse) ProtoMessage() {}
func (x *LogoutResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcq_v1_mcq_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use LogoutResponse.ProtoReflect.Descriptor instead.
func (*LogoutResponse) Descriptor() ([]byte, []int) {
return file_proto_mcq_v1_mcq_proto_rawDescGZIP(), []int{12}
}
type HealthRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *HealthRequest) Reset() {
*x = HealthRequest{}
mi := &file_proto_mcq_v1_mcq_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *HealthRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*HealthRequest) ProtoMessage() {}
func (x *HealthRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcq_v1_mcq_proto_msgTypes[13]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use HealthRequest.ProtoReflect.Descriptor instead.
func (*HealthRequest) Descriptor() ([]byte, []int) {
return file_proto_mcq_v1_mcq_proto_rawDescGZIP(), []int{13}
}
type HealthResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Status string `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *HealthResponse) Reset() {
*x = HealthResponse{}
mi := &file_proto_mcq_v1_mcq_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *HealthResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*HealthResponse) ProtoMessage() {}
func (x *HealthResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcq_v1_mcq_proto_msgTypes[14]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use HealthResponse.ProtoReflect.Descriptor instead.
func (*HealthResponse) Descriptor() ([]byte, []int) {
return file_proto_mcq_v1_mcq_proto_rawDescGZIP(), []int{14}
}
func (x *HealthResponse) GetStatus() string {
if x != nil {
return x.Status
}
return ""
}
var File_proto_mcq_v1_mcq_proto protoreflect.FileDescriptor
const file_proto_mcq_v1_mcq_proto_rawDesc = "" +
"\n" +
"\x16proto/mcq/v1/mcq.proto\x12\x06mcq.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\xc2\x01\n" +
"\bDocument\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x03R\x02id\x12\x12\n" +
"\x04slug\x18\x02 \x01(\tR\x04slug\x12\x14\n" +
"\x05title\x18\x03 \x01(\tR\x05title\x12\x12\n" +
"\x04body\x18\x04 \x01(\tR\x04body\x12\x1b\n" +
"\tpushed_by\x18\x05 \x01(\tR\bpushedBy\x127\n" +
"\tpushed_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\bpushedAt\x12\x12\n" +
"\x04read\x18\a \x01(\bR\x04read\"\x16\n" +
"\x14ListDocumentsRequest\"G\n" +
"\x15ListDocumentsResponse\x12.\n" +
"\tdocuments\x18\x01 \x03(\v2\x10.mcq.v1.DocumentR\tdocuments\"(\n" +
"\x12GetDocumentRequest\x12\x12\n" +
"\x04slug\x18\x01 \x01(\tR\x04slug\"R\n" +
"\x12PutDocumentRequest\x12\x12\n" +
"\x04slug\x18\x01 \x01(\tR\x04slug\x12\x14\n" +
"\x05title\x18\x02 \x01(\tR\x05title\x12\x12\n" +
"\x04body\x18\x03 \x01(\tR\x04body\"+\n" +
"\x15DeleteDocumentRequest\x12\x12\n" +
"\x04slug\x18\x01 \x01(\tR\x04slug\"\x18\n" +
"\x16DeleteDocumentResponse\"%\n" +
"\x0fMarkReadRequest\x12\x12\n" +
"\x04slug\x18\x01 \x01(\tR\x04slug\"'\n" +
"\x11MarkUnreadRequest\x12\x12\n" +
"\x04slug\x18\x01 \x01(\tR\x04slug\"c\n" +
"\fLoginRequest\x12\x1a\n" +
"\busername\x18\x01 \x01(\tR\busername\x12\x1a\n" +
"\bpassword\x18\x02 \x01(\tR\bpassword\x12\x1b\n" +
"\ttotp_code\x18\x03 \x01(\tR\btotpCode\"%\n" +
"\rLoginResponse\x12\x14\n" +
"\x05token\x18\x01 \x01(\tR\x05token\"%\n" +
"\rLogoutRequest\x12\x14\n" +
"\x05token\x18\x01 \x01(\tR\x05token\"\x10\n" +
"\x0eLogoutResponse\"\x0f\n" +
"\rHealthRequest\"(\n" +
"\x0eHealthResponse\x12\x16\n" +
"\x06status\x18\x01 \x01(\tR\x06status2\x9c\x03\n" +
"\x0fDocumentService\x12L\n" +
"\rListDocuments\x12\x1c.mcq.v1.ListDocumentsRequest\x1a\x1d.mcq.v1.ListDocumentsResponse\x12;\n" +
"\vGetDocument\x12\x1a.mcq.v1.GetDocumentRequest\x1a\x10.mcq.v1.Document\x12;\n" +
"\vPutDocument\x12\x1a.mcq.v1.PutDocumentRequest\x1a\x10.mcq.v1.Document\x12O\n" +
"\x0eDeleteDocument\x12\x1d.mcq.v1.DeleteDocumentRequest\x1a\x1e.mcq.v1.DeleteDocumentResponse\x125\n" +
"\bMarkRead\x12\x17.mcq.v1.MarkReadRequest\x1a\x10.mcq.v1.Document\x129\n" +
"\n" +
"MarkUnread\x12\x19.mcq.v1.MarkUnreadRequest\x1a\x10.mcq.v1.Document2|\n" +
"\vAuthService\x124\n" +
"\x05Login\x12\x14.mcq.v1.LoginRequest\x1a\x15.mcq.v1.LoginResponse\x127\n" +
"\x06Logout\x12\x15.mcq.v1.LogoutRequest\x1a\x16.mcq.v1.LogoutResponse2G\n" +
"\fAdminService\x127\n" +
"\x06Health\x12\x15.mcq.v1.HealthRequest\x1a\x16.mcq.v1.HealthResponseB*Z(git.wntrmute.dev/mc/mcq/gen/mcq/v1;mcqv1b\x06proto3"
var (
file_proto_mcq_v1_mcq_proto_rawDescOnce sync.Once
file_proto_mcq_v1_mcq_proto_rawDescData []byte
)
func file_proto_mcq_v1_mcq_proto_rawDescGZIP() []byte {
file_proto_mcq_v1_mcq_proto_rawDescOnce.Do(func() {
file_proto_mcq_v1_mcq_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_mcq_v1_mcq_proto_rawDesc), len(file_proto_mcq_v1_mcq_proto_rawDesc)))
})
return file_proto_mcq_v1_mcq_proto_rawDescData
}
var file_proto_mcq_v1_mcq_proto_msgTypes = make([]protoimpl.MessageInfo, 15)
var file_proto_mcq_v1_mcq_proto_goTypes = []any{
(*Document)(nil), // 0: mcq.v1.Document
(*ListDocumentsRequest)(nil), // 1: mcq.v1.ListDocumentsRequest
(*ListDocumentsResponse)(nil), // 2: mcq.v1.ListDocumentsResponse
(*GetDocumentRequest)(nil), // 3: mcq.v1.GetDocumentRequest
(*PutDocumentRequest)(nil), // 4: mcq.v1.PutDocumentRequest
(*DeleteDocumentRequest)(nil), // 5: mcq.v1.DeleteDocumentRequest
(*DeleteDocumentResponse)(nil), // 6: mcq.v1.DeleteDocumentResponse
(*MarkReadRequest)(nil), // 7: mcq.v1.MarkReadRequest
(*MarkUnreadRequest)(nil), // 8: mcq.v1.MarkUnreadRequest
(*LoginRequest)(nil), // 9: mcq.v1.LoginRequest
(*LoginResponse)(nil), // 10: mcq.v1.LoginResponse
(*LogoutRequest)(nil), // 11: mcq.v1.LogoutRequest
(*LogoutResponse)(nil), // 12: mcq.v1.LogoutResponse
(*HealthRequest)(nil), // 13: mcq.v1.HealthRequest
(*HealthResponse)(nil), // 14: mcq.v1.HealthResponse
(*timestamppb.Timestamp)(nil), // 15: google.protobuf.Timestamp
}
var file_proto_mcq_v1_mcq_proto_depIdxs = []int32{
15, // 0: mcq.v1.Document.pushed_at:type_name -> google.protobuf.Timestamp
0, // 1: mcq.v1.ListDocumentsResponse.documents:type_name -> mcq.v1.Document
1, // 2: mcq.v1.DocumentService.ListDocuments:input_type -> mcq.v1.ListDocumentsRequest
3, // 3: mcq.v1.DocumentService.GetDocument:input_type -> mcq.v1.GetDocumentRequest
4, // 4: mcq.v1.DocumentService.PutDocument:input_type -> mcq.v1.PutDocumentRequest
5, // 5: mcq.v1.DocumentService.DeleteDocument:input_type -> mcq.v1.DeleteDocumentRequest
7, // 6: mcq.v1.DocumentService.MarkRead:input_type -> mcq.v1.MarkReadRequest
8, // 7: mcq.v1.DocumentService.MarkUnread:input_type -> mcq.v1.MarkUnreadRequest
9, // 8: mcq.v1.AuthService.Login:input_type -> mcq.v1.LoginRequest
11, // 9: mcq.v1.AuthService.Logout:input_type -> mcq.v1.LogoutRequest
13, // 10: mcq.v1.AdminService.Health:input_type -> mcq.v1.HealthRequest
2, // 11: mcq.v1.DocumentService.ListDocuments:output_type -> mcq.v1.ListDocumentsResponse
0, // 12: mcq.v1.DocumentService.GetDocument:output_type -> mcq.v1.Document
0, // 13: mcq.v1.DocumentService.PutDocument:output_type -> mcq.v1.Document
6, // 14: mcq.v1.DocumentService.DeleteDocument:output_type -> mcq.v1.DeleteDocumentResponse
0, // 15: mcq.v1.DocumentService.MarkRead:output_type -> mcq.v1.Document
0, // 16: mcq.v1.DocumentService.MarkUnread:output_type -> mcq.v1.Document
10, // 17: mcq.v1.AuthService.Login:output_type -> mcq.v1.LoginResponse
12, // 18: mcq.v1.AuthService.Logout:output_type -> mcq.v1.LogoutResponse
14, // 19: mcq.v1.AdminService.Health:output_type -> mcq.v1.HealthResponse
11, // [11:20] is the sub-list for method output_type
2, // [2:11] is the sub-list for method input_type
2, // [2:2] is the sub-list for extension type_name
2, // [2:2] is the sub-list for extension extendee
0, // [0:2] is the sub-list for field type_name
}
func init() { file_proto_mcq_v1_mcq_proto_init() }
func file_proto_mcq_v1_mcq_proto_init() {
if File_proto_mcq_v1_mcq_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_mcq_v1_mcq_proto_rawDesc), len(file_proto_mcq_v1_mcq_proto_rawDesc)),
NumEnums: 0,
NumMessages: 15,
NumExtensions: 0,
NumServices: 3,
},
GoTypes: file_proto_mcq_v1_mcq_proto_goTypes,
DependencyIndexes: file_proto_mcq_v1_mcq_proto_depIdxs,
MessageInfos: file_proto_mcq_v1_mcq_proto_msgTypes,
}.Build()
File_proto_mcq_v1_mcq_proto = out.File
file_proto_mcq_v1_mcq_proto_goTypes = nil
file_proto_mcq_v1_mcq_proto_depIdxs = nil
}

565
gen/mcq/v1/mcq_grpc.pb.go Normal file
View File

@@ -0,0 +1,565 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v6.32.1
// source: proto/mcq/v1/mcq.proto
package mcqv1
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
DocumentService_ListDocuments_FullMethodName = "/mcq.v1.DocumentService/ListDocuments"
DocumentService_GetDocument_FullMethodName = "/mcq.v1.DocumentService/GetDocument"
DocumentService_PutDocument_FullMethodName = "/mcq.v1.DocumentService/PutDocument"
DocumentService_DeleteDocument_FullMethodName = "/mcq.v1.DocumentService/DeleteDocument"
DocumentService_MarkRead_FullMethodName = "/mcq.v1.DocumentService/MarkRead"
DocumentService_MarkUnread_FullMethodName = "/mcq.v1.DocumentService/MarkUnread"
)
// DocumentServiceClient is the client API for DocumentService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// DocumentService manages queued documents for reading.
type DocumentServiceClient interface {
ListDocuments(ctx context.Context, in *ListDocumentsRequest, opts ...grpc.CallOption) (*ListDocumentsResponse, error)
GetDocument(ctx context.Context, in *GetDocumentRequest, opts ...grpc.CallOption) (*Document, error)
PutDocument(ctx context.Context, in *PutDocumentRequest, opts ...grpc.CallOption) (*Document, error)
DeleteDocument(ctx context.Context, in *DeleteDocumentRequest, opts ...grpc.CallOption) (*DeleteDocumentResponse, error)
MarkRead(ctx context.Context, in *MarkReadRequest, opts ...grpc.CallOption) (*Document, error)
MarkUnread(ctx context.Context, in *MarkUnreadRequest, opts ...grpc.CallOption) (*Document, error)
}
type documentServiceClient struct {
cc grpc.ClientConnInterface
}
func NewDocumentServiceClient(cc grpc.ClientConnInterface) DocumentServiceClient {
return &documentServiceClient{cc}
}
func (c *documentServiceClient) ListDocuments(ctx context.Context, in *ListDocumentsRequest, opts ...grpc.CallOption) (*ListDocumentsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListDocumentsResponse)
err := c.cc.Invoke(ctx, DocumentService_ListDocuments_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *documentServiceClient) GetDocument(ctx context.Context, in *GetDocumentRequest, opts ...grpc.CallOption) (*Document, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Document)
err := c.cc.Invoke(ctx, DocumentService_GetDocument_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *documentServiceClient) PutDocument(ctx context.Context, in *PutDocumentRequest, opts ...grpc.CallOption) (*Document, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Document)
err := c.cc.Invoke(ctx, DocumentService_PutDocument_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *documentServiceClient) DeleteDocument(ctx context.Context, in *DeleteDocumentRequest, opts ...grpc.CallOption) (*DeleteDocumentResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DeleteDocumentResponse)
err := c.cc.Invoke(ctx, DocumentService_DeleteDocument_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *documentServiceClient) MarkRead(ctx context.Context, in *MarkReadRequest, opts ...grpc.CallOption) (*Document, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Document)
err := c.cc.Invoke(ctx, DocumentService_MarkRead_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *documentServiceClient) MarkUnread(ctx context.Context, in *MarkUnreadRequest, opts ...grpc.CallOption) (*Document, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Document)
err := c.cc.Invoke(ctx, DocumentService_MarkUnread_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// DocumentServiceServer is the server API for DocumentService service.
// All implementations must embed UnimplementedDocumentServiceServer
// for forward compatibility.
//
// DocumentService manages queued documents for reading.
type DocumentServiceServer interface {
ListDocuments(context.Context, *ListDocumentsRequest) (*ListDocumentsResponse, error)
GetDocument(context.Context, *GetDocumentRequest) (*Document, error)
PutDocument(context.Context, *PutDocumentRequest) (*Document, error)
DeleteDocument(context.Context, *DeleteDocumentRequest) (*DeleteDocumentResponse, error)
MarkRead(context.Context, *MarkReadRequest) (*Document, error)
MarkUnread(context.Context, *MarkUnreadRequest) (*Document, error)
mustEmbedUnimplementedDocumentServiceServer()
}
// UnimplementedDocumentServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedDocumentServiceServer struct{}
func (UnimplementedDocumentServiceServer) ListDocuments(context.Context, *ListDocumentsRequest) (*ListDocumentsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListDocuments not implemented")
}
func (UnimplementedDocumentServiceServer) GetDocument(context.Context, *GetDocumentRequest) (*Document, error) {
return nil, status.Error(codes.Unimplemented, "method GetDocument not implemented")
}
func (UnimplementedDocumentServiceServer) PutDocument(context.Context, *PutDocumentRequest) (*Document, error) {
return nil, status.Error(codes.Unimplemented, "method PutDocument not implemented")
}
func (UnimplementedDocumentServiceServer) DeleteDocument(context.Context, *DeleteDocumentRequest) (*DeleteDocumentResponse, error) {
return nil, status.Error(codes.Unimplemented, "method DeleteDocument not implemented")
}
func (UnimplementedDocumentServiceServer) MarkRead(context.Context, *MarkReadRequest) (*Document, error) {
return nil, status.Error(codes.Unimplemented, "method MarkRead not implemented")
}
func (UnimplementedDocumentServiceServer) MarkUnread(context.Context, *MarkUnreadRequest) (*Document, error) {
return nil, status.Error(codes.Unimplemented, "method MarkUnread not implemented")
}
func (UnimplementedDocumentServiceServer) mustEmbedUnimplementedDocumentServiceServer() {}
func (UnimplementedDocumentServiceServer) testEmbeddedByValue() {}
// UnsafeDocumentServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to DocumentServiceServer will
// result in compilation errors.
type UnsafeDocumentServiceServer interface {
mustEmbedUnimplementedDocumentServiceServer()
}
func RegisterDocumentServiceServer(s grpc.ServiceRegistrar, srv DocumentServiceServer) {
// If the following call panics, it indicates UnimplementedDocumentServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&DocumentService_ServiceDesc, srv)
}
func _DocumentService_ListDocuments_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListDocumentsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DocumentServiceServer).ListDocuments(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DocumentService_ListDocuments_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DocumentServiceServer).ListDocuments(ctx, req.(*ListDocumentsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _DocumentService_GetDocument_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetDocumentRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DocumentServiceServer).GetDocument(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DocumentService_GetDocument_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DocumentServiceServer).GetDocument(ctx, req.(*GetDocumentRequest))
}
return interceptor(ctx, in, info, handler)
}
func _DocumentService_PutDocument_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(PutDocumentRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DocumentServiceServer).PutDocument(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DocumentService_PutDocument_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DocumentServiceServer).PutDocument(ctx, req.(*PutDocumentRequest))
}
return interceptor(ctx, in, info, handler)
}
func _DocumentService_DeleteDocument_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteDocumentRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DocumentServiceServer).DeleteDocument(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DocumentService_DeleteDocument_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DocumentServiceServer).DeleteDocument(ctx, req.(*DeleteDocumentRequest))
}
return interceptor(ctx, in, info, handler)
}
func _DocumentService_MarkRead_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(MarkReadRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DocumentServiceServer).MarkRead(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DocumentService_MarkRead_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DocumentServiceServer).MarkRead(ctx, req.(*MarkReadRequest))
}
return interceptor(ctx, in, info, handler)
}
func _DocumentService_MarkUnread_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(MarkUnreadRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DocumentServiceServer).MarkUnread(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DocumentService_MarkUnread_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DocumentServiceServer).MarkUnread(ctx, req.(*MarkUnreadRequest))
}
return interceptor(ctx, in, info, handler)
}
// DocumentService_ServiceDesc is the grpc.ServiceDesc for DocumentService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var DocumentService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "mcq.v1.DocumentService",
HandlerType: (*DocumentServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "ListDocuments",
Handler: _DocumentService_ListDocuments_Handler,
},
{
MethodName: "GetDocument",
Handler: _DocumentService_GetDocument_Handler,
},
{
MethodName: "PutDocument",
Handler: _DocumentService_PutDocument_Handler,
},
{
MethodName: "DeleteDocument",
Handler: _DocumentService_DeleteDocument_Handler,
},
{
MethodName: "MarkRead",
Handler: _DocumentService_MarkRead_Handler,
},
{
MethodName: "MarkUnread",
Handler: _DocumentService_MarkUnread_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/mcq/v1/mcq.proto",
}
const (
AuthService_Login_FullMethodName = "/mcq.v1.AuthService/Login"
AuthService_Logout_FullMethodName = "/mcq.v1.AuthService/Logout"
)
// AuthServiceClient is the client API for AuthService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// AuthService handles MCIAS login and logout.
type AuthServiceClient interface {
Login(ctx context.Context, in *LoginRequest, opts ...grpc.CallOption) (*LoginResponse, error)
Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*LogoutResponse, error)
}
type authServiceClient struct {
cc grpc.ClientConnInterface
}
func NewAuthServiceClient(cc grpc.ClientConnInterface) AuthServiceClient {
return &authServiceClient{cc}
}
func (c *authServiceClient) Login(ctx context.Context, in *LoginRequest, opts ...grpc.CallOption) (*LoginResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(LoginResponse)
err := c.cc.Invoke(ctx, AuthService_Login_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *authServiceClient) Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*LogoutResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(LogoutResponse)
err := c.cc.Invoke(ctx, AuthService_Logout_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AuthServiceServer is the server API for AuthService service.
// All implementations must embed UnimplementedAuthServiceServer
// for forward compatibility.
//
// AuthService handles MCIAS login and logout.
type AuthServiceServer interface {
Login(context.Context, *LoginRequest) (*LoginResponse, error)
Logout(context.Context, *LogoutRequest) (*LogoutResponse, error)
mustEmbedUnimplementedAuthServiceServer()
}
// UnimplementedAuthServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedAuthServiceServer struct{}
func (UnimplementedAuthServiceServer) Login(context.Context, *LoginRequest) (*LoginResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Login not implemented")
}
func (UnimplementedAuthServiceServer) Logout(context.Context, *LogoutRequest) (*LogoutResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Logout not implemented")
}
func (UnimplementedAuthServiceServer) mustEmbedUnimplementedAuthServiceServer() {}
func (UnimplementedAuthServiceServer) testEmbeddedByValue() {}
// UnsafeAuthServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to AuthServiceServer will
// result in compilation errors.
type UnsafeAuthServiceServer interface {
mustEmbedUnimplementedAuthServiceServer()
}
func RegisterAuthServiceServer(s grpc.ServiceRegistrar, srv AuthServiceServer) {
// If the following call panics, it indicates UnimplementedAuthServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&AuthService_ServiceDesc, srv)
}
func _AuthService_Login_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(LoginRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AuthServiceServer).Login(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AuthService_Login_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AuthServiceServer).Login(ctx, req.(*LoginRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AuthService_Logout_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(LogoutRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AuthServiceServer).Logout(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AuthService_Logout_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AuthServiceServer).Logout(ctx, req.(*LogoutRequest))
}
return interceptor(ctx, in, info, handler)
}
// AuthService_ServiceDesc is the grpc.ServiceDesc for AuthService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var AuthService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "mcq.v1.AuthService",
HandlerType: (*AuthServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "Login",
Handler: _AuthService_Login_Handler,
},
{
MethodName: "Logout",
Handler: _AuthService_Logout_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/mcq/v1/mcq.proto",
}
const (
AdminService_Health_FullMethodName = "/mcq.v1.AdminService/Health"
)
// AdminServiceClient is the client API for AdminService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// AdminService provides health checks.
type AdminServiceClient interface {
Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error)
}
type adminServiceClient struct {
cc grpc.ClientConnInterface
}
func NewAdminServiceClient(cc grpc.ClientConnInterface) AdminServiceClient {
return &adminServiceClient{cc}
}
func (c *adminServiceClient) Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(HealthResponse)
err := c.cc.Invoke(ctx, AdminService_Health_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AdminServiceServer is the server API for AdminService service.
// All implementations must embed UnimplementedAdminServiceServer
// for forward compatibility.
//
// AdminService provides health checks.
type AdminServiceServer interface {
Health(context.Context, *HealthRequest) (*HealthResponse, error)
mustEmbedUnimplementedAdminServiceServer()
}
// UnimplementedAdminServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedAdminServiceServer struct{}
func (UnimplementedAdminServiceServer) Health(context.Context, *HealthRequest) (*HealthResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Health not implemented")
}
func (UnimplementedAdminServiceServer) mustEmbedUnimplementedAdminServiceServer() {}
func (UnimplementedAdminServiceServer) testEmbeddedByValue() {}
// UnsafeAdminServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to AdminServiceServer will
// result in compilation errors.
type UnsafeAdminServiceServer interface {
mustEmbedUnimplementedAdminServiceServer()
}
func RegisterAdminServiceServer(s grpc.ServiceRegistrar, srv AdminServiceServer) {
// If the following call panics, it indicates UnimplementedAdminServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&AdminService_ServiceDesc, srv)
}
func _AdminService_Health_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(HealthRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminServiceServer).Health(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminService_Health_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminServiceServer).Health(ctx, req.(*HealthRequest))
}
return interceptor(ctx, in, info, handler)
}
// AdminService_ServiceDesc is the grpc.ServiceDesc for AdminService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var AdminService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "mcq.v1.AdminService",
HandlerType: (*AdminServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "Health",
Handler: _AdminService_Health_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/mcq/v1/mcq.proto",
}

34
go.mod Normal file
View File

@@ -0,0 +1,34 @@
module git.wntrmute.dev/mc/mcq
go 1.25.7
require (
git.wntrmute.dev/mc/mcdsl v1.2.0
github.com/alecthomas/chroma/v2 v2.18.0
github.com/go-chi/chi/v5 v5.2.5
github.com/spf13/cobra v1.10.2
github.com/yuin/goldmark v1.7.12
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
google.golang.org/grpc v1.79.3
google.golang.org/protobuf v1.36.11
)
require (
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.47.0 // indirect
)

126
go.sum Normal file
View File

@@ -0,0 +1,126 @@
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=
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=
github.com/alecthomas/chroma/v2 v2.18.0 h1:6h53Q4hW83SuF+jcsp7CVhLsMozzvQvO8HBbKQW+gn4=
github.com/alecthomas/chroma/v2 v2.18.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY=
github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

23
internal/db/db.go Normal file
View File

@@ -0,0 +1,23 @@
package db
import (
"database/sql"
"fmt"
mcdsldb "git.wntrmute.dev/mc/mcdsl/db"
)
// DB wraps a SQLite database connection.
type DB struct {
*sql.DB
}
// Open opens (or creates) a SQLite database at the given path with the
// standard Metacircular pragmas: WAL mode, foreign keys, busy timeout.
func Open(path string) (*DB, error) {
sqlDB, err := mcdsldb.Open(path)
if err != nil {
return nil, fmt.Errorf("db: %w", err)
}
return &DB{sqlDB}, nil
}

121
internal/db/documents.go Normal file
View File

@@ -0,0 +1,121 @@
package db
import (
"database/sql"
"errors"
"time"
)
// ErrNotFound is returned when a document does not exist.
var ErrNotFound = errors.New("db: not found")
// Document represents a queued document.
type Document struct {
ID int64 `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Body string `json:"body"`
PushedBy string `json:"pushed_by"`
PushedAt string `json:"pushed_at"`
Read bool `json:"read"`
}
// ListDocuments returns all documents ordered by most recently pushed.
func (d *DB) ListDocuments() ([]Document, error) {
rows, err := d.Query(`SELECT id, slug, title, body, pushed_by, pushed_at, read FROM documents ORDER BY pushed_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var docs []Document
for rows.Next() {
var doc Document
if err := rows.Scan(&doc.ID, &doc.Slug, &doc.Title, &doc.Body, &doc.PushedBy, &doc.PushedAt, &doc.Read); err != nil {
return nil, err
}
docs = append(docs, doc)
}
return docs, rows.Err()
}
// GetDocument returns a single document by slug.
func (d *DB) GetDocument(slug string) (*Document, error) {
var doc Document
err := d.QueryRow(
`SELECT id, slug, title, body, pushed_by, pushed_at, read FROM documents WHERE slug = ?`,
slug,
).Scan(&doc.ID, &doc.Slug, &doc.Title, &doc.Body, &doc.PushedBy, &doc.PushedAt, &doc.Read)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &doc, nil
}
// PutDocument creates or updates a document by slug (upsert).
func (d *DB) PutDocument(slug, title, body, pushedBy string) (*Document, error) {
now := time.Now().UTC().Format(time.RFC3339)
_, err := d.Exec(`
INSERT INTO documents (slug, title, body, pushed_by, pushed_at, read)
VALUES (?, ?, ?, ?, ?, 0)
ON CONFLICT(slug) DO UPDATE SET
title = excluded.title,
body = excluded.body,
pushed_by = excluded.pushed_by,
pushed_at = excluded.pushed_at,
read = 0`,
slug, title, body, pushedBy, now,
)
if err != nil {
return nil, err
}
return d.GetDocument(slug)
}
// DeleteDocument removes a document by slug.
func (d *DB) DeleteDocument(slug string) error {
res, err := d.Exec(`DELETE FROM documents WHERE slug = ?`, slug)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
return nil
}
// MarkRead sets the read flag on a document.
func (d *DB) MarkRead(slug string) (*Document, error) {
return d.setRead(slug, true)
}
// MarkUnread clears the read flag on a document.
func (d *DB) MarkUnread(slug string) (*Document, error) {
return d.setRead(slug, false)
}
func (d *DB) setRead(slug string, read bool) (*Document, error) {
val := 0
if read {
val = 1
}
res, err := d.Exec(`UPDATE documents SET read = ? WHERE slug = ?`, val, slug)
if err != nil {
return nil, err
}
n, err := res.RowsAffected()
if err != nil {
return nil, err
}
if n == 0 {
return nil, ErrNotFound
}
return d.GetDocument(slug)
}

View File

@@ -0,0 +1,145 @@
package db
import (
"path/filepath"
"testing"
)
func openTestDB(t *testing.T) *DB {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test.db")
database, err := Open(dbPath)
if err != nil {
t.Fatal(err)
}
if err := database.Migrate(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { database.Close() })
return database
}
func TestPutAndGetDocument(t *testing.T) {
db := openTestDB(t)
doc, err := db.PutDocument("test-slug", "Test Title", "# Hello", "kyle")
if err != nil {
t.Fatal(err)
}
if doc.Slug != "test-slug" {
t.Errorf("slug = %q, want %q", doc.Slug, "test-slug")
}
if doc.Title != "Test Title" {
t.Errorf("title = %q, want %q", doc.Title, "Test Title")
}
if doc.Body != "# Hello" {
t.Errorf("body = %q, want %q", doc.Body, "# Hello")
}
if doc.PushedBy != "kyle" {
t.Errorf("pushed_by = %q, want %q", doc.PushedBy, "kyle")
}
if doc.Read {
t.Error("new document should not be read")
}
got, err := db.GetDocument("test-slug")
if err != nil {
t.Fatal(err)
}
if got.Title != "Test Title" {
t.Errorf("got title = %q, want %q", got.Title, "Test Title")
}
}
func TestPutDocumentUpsert(t *testing.T) {
db := openTestDB(t)
_, err := db.PutDocument("slug", "V1", "body v1", "alice")
if err != nil {
t.Fatal(err)
}
// Mark as read.
_, err = db.MarkRead("slug")
if err != nil {
t.Fatal(err)
}
// Upsert — should replace and reset read flag.
doc, err := db.PutDocument("slug", "V2", "body v2", "bob")
if err != nil {
t.Fatal(err)
}
if doc.Title != "V2" {
t.Errorf("title = %q, want V2", doc.Title)
}
if doc.Body != "body v2" {
t.Errorf("body = %q, want body v2", doc.Body)
}
if doc.PushedBy != "bob" {
t.Errorf("pushed_by = %q, want bob", doc.PushedBy)
}
if doc.Read {
t.Error("upsert should reset read flag")
}
}
func TestListDocuments(t *testing.T) {
db := openTestDB(t)
_, _ = db.PutDocument("a", "A", "body", "user")
_, _ = db.PutDocument("b", "B", "body", "user")
docs, err := db.ListDocuments()
if err != nil {
t.Fatal(err)
}
if len(docs) != 2 {
t.Fatalf("got %d docs, want 2", len(docs))
}
}
func TestDeleteDocument(t *testing.T) {
db := openTestDB(t)
_, _ = db.PutDocument("del", "Del", "body", "user")
if err := db.DeleteDocument("del"); err != nil {
t.Fatal(err)
}
_, err := db.GetDocument("del")
if err != ErrNotFound {
t.Errorf("got err = %v, want ErrNotFound", err)
}
}
func TestDeleteDocumentNotFound(t *testing.T) {
db := openTestDB(t)
err := db.DeleteDocument("nope")
if err != ErrNotFound {
t.Errorf("got err = %v, want ErrNotFound", err)
}
}
func TestMarkReadUnread(t *testing.T) {
db := openTestDB(t)
_, _ = db.PutDocument("rw", "RW", "body", "user")
doc, err := db.MarkRead("rw")
if err != nil {
t.Fatal(err)
}
if !doc.Read {
t.Error("expected read=true after MarkRead")
}
doc, err = db.MarkUnread("rw")
if err != nil {
t.Fatal(err)
}
if doc.Read {
t.Error("expected read=false after MarkUnread")
}
}

28
internal/db/migrate.go Normal file
View File

@@ -0,0 +1,28 @@
package db
import (
mcdsldb "git.wntrmute.dev/mc/mcdsl/db"
)
// Migrations is the ordered list of MCQ schema migrations.
var Migrations = []mcdsldb.Migration{
{
Version: 1,
Name: "documents",
SQL: `
CREATE TABLE IF NOT EXISTS documents (
id INTEGER PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
body TEXT NOT NULL,
pushed_by TEXT NOT NULL,
pushed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
read INTEGER NOT NULL DEFAULT 0
);`,
},
}
// Migrate applies all pending migrations.
func (d *DB) Migrate() error {
return mcdsldb.Migrate(d.DB, Migrations)
}

View File

@@ -0,0 +1,20 @@
package grpcserver
import (
"context"
pb "git.wntrmute.dev/mc/mcq/gen/mcq/v1"
"git.wntrmute.dev/mc/mcq/internal/db"
)
type adminService struct {
pb.UnimplementedAdminServiceServer
db *db.DB
}
func (s *adminService) Health(_ context.Context, _ *pb.HealthRequest) (*pb.HealthResponse, error) {
if err := s.db.Ping(); err != nil {
return &pb.HealthResponse{Status: "unhealthy"}, nil
}
return &pb.HealthResponse{Status: "ok"}, nil
}

View File

@@ -0,0 +1,38 @@
package grpcserver
import (
"context"
"errors"
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "git.wntrmute.dev/mc/mcq/gen/mcq/v1"
)
type authService struct {
pb.UnimplementedAuthServiceServer
auth *mcdslauth.Authenticator
}
func (s *authService) Login(_ context.Context, req *pb.LoginRequest) (*pb.LoginResponse, error) {
token, _, err := s.auth.Login(req.Username, req.Password, req.TotpCode)
if err != nil {
if errors.Is(err, mcdslauth.ErrInvalidCredentials) {
return nil, status.Error(codes.Unauthenticated, "invalid credentials")
}
if errors.Is(err, mcdslauth.ErrForbidden) {
return nil, status.Error(codes.PermissionDenied, "access denied by login policy")
}
return nil, status.Error(codes.Unavailable, "authentication service unavailable")
}
return &pb.LoginResponse{Token: token}, nil
}
func (s *authService) Logout(_ context.Context, req *pb.LogoutRequest) (*pb.LogoutResponse, error) {
if err := s.auth.Logout(req.Token); err != nil {
return nil, status.Error(codes.Internal, "logout failed")
}
return &pb.LogoutResponse{}, nil
}

View File

@@ -0,0 +1,140 @@
package grpcserver
import (
"context"
"errors"
"log/slog"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
mcdslgrpc "git.wntrmute.dev/mc/mcdsl/grpcserver"
pb "git.wntrmute.dev/mc/mcq/gen/mcq/v1"
"git.wntrmute.dev/mc/mcq/internal/db"
)
type documentService struct {
pb.UnimplementedDocumentServiceServer
db *db.DB
logger *slog.Logger
}
func (s *documentService) ListDocuments(_ context.Context, _ *pb.ListDocumentsRequest) (*pb.ListDocumentsResponse, error) {
docs, err := s.db.ListDocuments()
if err != nil {
return nil, status.Error(codes.Internal, "failed to list documents")
}
resp := &pb.ListDocumentsResponse{}
for _, d := range docs {
resp.Documents = append(resp.Documents, s.docToProto(d))
}
return resp, nil
}
func (s *documentService) GetDocument(_ context.Context, req *pb.GetDocumentRequest) (*pb.Document, error) {
if req.Slug == "" {
return nil, status.Error(codes.InvalidArgument, "slug is required")
}
doc, err := s.db.GetDocument(req.Slug)
if errors.Is(err, db.ErrNotFound) {
return nil, status.Error(codes.NotFound, "document not found")
}
if err != nil {
return nil, status.Error(codes.Internal, "failed to get document")
}
return s.docToProto(*doc), nil
}
func (s *documentService) PutDocument(ctx context.Context, req *pb.PutDocumentRequest) (*pb.Document, error) {
if req.Slug == "" {
return nil, status.Error(codes.InvalidArgument, "slug is required")
}
if req.Title == "" {
return nil, status.Error(codes.InvalidArgument, "title is required")
}
if req.Body == "" {
return nil, status.Error(codes.InvalidArgument, "body is required")
}
pushedBy := "unknown"
if info := mcdslgrpc.TokenInfoFromContext(ctx); info != nil {
pushedBy = info.Username
}
doc, err := s.db.PutDocument(req.Slug, req.Title, req.Body, pushedBy)
if err != nil {
return nil, status.Error(codes.Internal, "failed to save document")
}
return s.docToProto(*doc), nil
}
func (s *documentService) DeleteDocument(_ context.Context, req *pb.DeleteDocumentRequest) (*pb.DeleteDocumentResponse, error) {
if req.Slug == "" {
return nil, status.Error(codes.InvalidArgument, "slug is required")
}
err := s.db.DeleteDocument(req.Slug)
if errors.Is(err, db.ErrNotFound) {
return nil, status.Error(codes.NotFound, "document not found")
}
if err != nil {
return nil, status.Error(codes.Internal, "failed to delete document")
}
return &pb.DeleteDocumentResponse{}, nil
}
func (s *documentService) MarkRead(_ context.Context, req *pb.MarkReadRequest) (*pb.Document, error) {
if req.Slug == "" {
return nil, status.Error(codes.InvalidArgument, "slug is required")
}
doc, err := s.db.MarkRead(req.Slug)
if errors.Is(err, db.ErrNotFound) {
return nil, status.Error(codes.NotFound, "document not found")
}
if err != nil {
return nil, status.Error(codes.Internal, "failed to mark read")
}
return s.docToProto(*doc), nil
}
func (s *documentService) MarkUnread(_ context.Context, req *pb.MarkUnreadRequest) (*pb.Document, error) {
if req.Slug == "" {
return nil, status.Error(codes.InvalidArgument, "slug is required")
}
doc, err := s.db.MarkUnread(req.Slug)
if errors.Is(err, db.ErrNotFound) {
return nil, status.Error(codes.NotFound, "document not found")
}
if err != nil {
return nil, status.Error(codes.Internal, "failed to mark unread")
}
return s.docToProto(*doc), nil
}
func (s *documentService) docToProto(d db.Document) *pb.Document {
return &pb.Document{
Id: d.ID,
Slug: d.Slug,
Title: d.Title,
Body: d.Body,
PushedBy: d.PushedBy,
PushedAt: s.parseTimestamp(d.PushedAt),
Read: d.Read,
}
}
func (s *documentService) parseTimestamp(v string) *timestamppb.Timestamp {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
s.logger.Warn("failed to parse timestamp", "value", v, "error", err)
return nil
}
return timestamppb.New(t)
}

View File

@@ -0,0 +1,40 @@
package grpcserver
import (
mcdslgrpc "git.wntrmute.dev/mc/mcdsl/grpcserver"
)
// methodMap builds the mcdsl grpcserver.MethodMap for MCQ.
//
// Adding a new RPC without adding it to the correct map is a security
// defect — the mcdsl auth interceptor denies unmapped methods by default.
func methodMap() mcdslgrpc.MethodMap {
return mcdslgrpc.MethodMap{
Public: publicMethods(),
AuthRequired: authRequiredMethods(),
AdminRequired: adminRequiredMethods(),
}
}
func publicMethods() map[string]bool {
return map[string]bool{
"/mcq.v1.AdminService/Health": true,
"/mcq.v1.AuthService/Login": true,
}
}
func authRequiredMethods() map[string]bool {
return map[string]bool{
"/mcq.v1.AuthService/Logout": true,
"/mcq.v1.DocumentService/ListDocuments": true,
"/mcq.v1.DocumentService/GetDocument": true,
"/mcq.v1.DocumentService/PutDocument": true,
"/mcq.v1.DocumentService/DeleteDocument": true,
"/mcq.v1.DocumentService/MarkRead": true,
"/mcq.v1.DocumentService/MarkUnread": true,
}
}
func adminRequiredMethods() map[string]bool {
return map[string]bool{}
}

View File

@@ -0,0 +1,49 @@
package grpcserver
import (
"log/slog"
"net"
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
mcdslgrpc "git.wntrmute.dev/mc/mcdsl/grpcserver"
pb "git.wntrmute.dev/mc/mcq/gen/mcq/v1"
"git.wntrmute.dev/mc/mcq/internal/db"
)
// Deps holds the dependencies injected into the gRPC server.
type Deps struct {
DB *db.DB
Authenticator *mcdslauth.Authenticator
}
// Server wraps a mcdsl grpcserver.Server with MCQ-specific services.
type Server struct {
srv *mcdslgrpc.Server
}
// New creates a configured gRPC server with MCQ services registered.
func New(certFile, keyFile string, deps Deps, logger *slog.Logger) (*Server, error) {
srv, err := mcdslgrpc.New(certFile, keyFile, deps.Authenticator, methodMap(), logger, nil)
if err != nil {
return nil, err
}
s := &Server{srv: srv}
pb.RegisterAdminServiceServer(srv.GRPCServer, &adminService{db: deps.DB})
pb.RegisterAuthServiceServer(srv.GRPCServer, &authService{auth: deps.Authenticator})
pb.RegisterDocumentServiceServer(srv.GRPCServer, &documentService{db: deps.DB, logger: logger})
return s, nil
}
// Serve starts the gRPC server on the given listener.
func (s *Server) Serve(lis net.Listener) error {
return s.srv.GRPCServer.Serve(lis)
}
// GracefulStop gracefully stops the gRPC server.
func (s *Server) GracefulStop() {
s.srv.Stop()
}

50
internal/render/render.go Normal file
View File

@@ -0,0 +1,50 @@
package render
import (
"bytes"
"fmt"
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
"github.com/yuin/goldmark"
highlighting "github.com/yuin/goldmark-highlighting/v2"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
)
// Renderer converts markdown to HTML using goldmark.
type Renderer struct {
md goldmark.Markdown
}
// New creates a Renderer with GFM, syntax highlighting, and heading anchors.
func New() *Renderer {
md := goldmark.New(
goldmark.WithExtensions(
extension.GFM,
highlighting.NewHighlighting(
highlighting.WithStyle("github"),
highlighting.WithFormatOptions(
chromahtml.WithClasses(true),
),
),
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
),
goldmark.WithRendererOptions(
html.WithUnsafe(),
),
)
return &Renderer{md: md}
}
// Render converts markdown source to HTML.
func (r *Renderer) Render(source []byte) (string, error) {
var buf bytes.Buffer
if err := r.md.Convert(source, &buf); err != nil {
return "", fmt.Errorf("render markdown: %w", err)
}
return buf.String(), nil
}

62
internal/server/auth.go Normal file
View File

@@ -0,0 +1,62 @@
package server
import (
"encoding/json"
"errors"
"net/http"
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
)
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
TOTPCode string `json:"totp_code"`
}
type loginResponse struct {
Token string `json:"token"`
}
func loginHandler(auth *mcdslauth.Authenticator) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
token, _, err := auth.Login(req.Username, req.Password, req.TOTPCode)
if err != nil {
if errors.Is(err, mcdslauth.ErrInvalidCredentials) {
writeError(w, http.StatusUnauthorized, "invalid credentials")
return
}
if errors.Is(err, mcdslauth.ErrForbidden) {
writeError(w, http.StatusForbidden, "access denied by login policy")
return
}
writeError(w, http.StatusServiceUnavailable, "authentication service unavailable")
return
}
writeJSON(w, http.StatusOK, loginResponse{Token: token})
}
}
func logoutHandler(auth *mcdslauth.Authenticator) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := extractBearerToken(r)
if token == "" {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
if err := auth.Logout(token); err != nil {
writeError(w, http.StatusInternalServerError, "logout failed")
return
}
w.WriteHeader(http.StatusNoContent)
}
}

View File

@@ -0,0 +1,131 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"git.wntrmute.dev/mc/mcq/internal/db"
)
type putDocumentRequest struct {
Title string `json:"title"`
Body string `json:"body"`
}
func listDocumentsHandler(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
docs, err := database.ListDocuments()
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list documents")
return
}
if docs == nil {
docs = []db.Document{}
}
writeJSON(w, http.StatusOK, map[string]any{"documents": docs})
}
}
func getDocumentHandler(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
doc, err := database.GetDocument(slug)
if errors.Is(err, db.ErrNotFound) {
writeError(w, http.StatusNotFound, "document not found")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to get document")
return
}
writeJSON(w, http.StatusOK, doc)
}
}
func putDocumentHandler(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
var req putDocumentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Title == "" {
writeError(w, http.StatusBadRequest, "title is required")
return
}
if req.Body == "" {
writeError(w, http.StatusBadRequest, "body is required")
return
}
info := tokenInfoFromContext(r.Context())
pushedBy := "unknown"
if info != nil {
pushedBy = info.Username
}
doc, err := database.PutDocument(slug, req.Title, req.Body, pushedBy)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to save document")
return
}
writeJSON(w, http.StatusOK, doc)
}
}
func deleteDocumentHandler(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
err := database.DeleteDocument(slug)
if errors.Is(err, db.ErrNotFound) {
writeError(w, http.StatusNotFound, "document not found")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete document")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func markReadHandler(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
doc, err := database.MarkRead(slug)
if errors.Is(err, db.ErrNotFound) {
writeError(w, http.StatusNotFound, "document not found")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to mark read")
return
}
writeJSON(w, http.StatusOK, doc)
}
}
func markUnreadHandler(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
doc, err := database.MarkUnread(slug)
if errors.Is(err, db.ErrNotFound) {
writeError(w, http.StatusNotFound, "document not found")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to mark unread")
return
}
writeJSON(w, http.StatusOK, doc)
}
}

View File

@@ -0,0 +1,84 @@
package server
import (
"context"
"log/slog"
"net/http"
"strings"
"time"
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
)
type contextKey string
const tokenInfoKey contextKey = "tokenInfo"
// requireAuth returns middleware that validates Bearer tokens via MCIAS.
func requireAuth(auth *mcdslauth.Authenticator) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := extractBearerToken(r)
if token == "" {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
info, err := auth.ValidateToken(token)
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid or expired token")
return
}
ctx := context.WithValue(r.Context(), tokenInfoKey, info)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// tokenInfoFromContext extracts the TokenInfo from the request context.
func tokenInfoFromContext(ctx context.Context) *mcdslauth.TokenInfo {
info, _ := ctx.Value(tokenInfoKey).(*mcdslauth.TokenInfo)
return info
}
// extractBearerToken extracts a bearer token from the Authorization header.
func extractBearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
if h == "" {
return ""
}
const prefix = "Bearer "
if !strings.HasPrefix(h, prefix) {
return ""
}
return strings.TrimSpace(h[len(prefix):])
}
// loggingMiddleware logs HTTP requests.
func loggingMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(sw, r)
logger.Info("http",
"method", r.Method,
"path", r.URL.Path,
"status", sw.status,
"duration", time.Since(start),
"remote", r.RemoteAddr,
)
})
}
}
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}

54
internal/server/routes.go Normal file
View File

@@ -0,0 +1,54 @@
package server
import (
"encoding/json"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
"git.wntrmute.dev/mc/mcdsl/health"
"git.wntrmute.dev/mc/mcq/internal/db"
)
// Deps holds dependencies injected into the REST handlers.
type Deps struct {
DB *db.DB
Auth *mcdslauth.Authenticator
Logger *slog.Logger
}
// RegisterRoutes adds all MCQ REST endpoints to the given router.
func RegisterRoutes(r chi.Router, deps Deps) {
// Public endpoints.
r.Post("/v1/auth/login", loginHandler(deps.Auth))
r.Get("/v1/health", health.Handler(deps.DB.DB))
// Authenticated endpoints.
r.Group(func(r chi.Router) {
r.Use(requireAuth(deps.Auth))
r.Post("/v1/auth/logout", logoutHandler(deps.Auth))
r.Get("/v1/documents", listDocumentsHandler(deps.DB))
r.Get("/v1/documents/{slug}", getDocumentHandler(deps.DB))
r.Put("/v1/documents/{slug}", putDocumentHandler(deps.DB))
r.Delete("/v1/documents/{slug}", deleteDocumentHandler(deps.DB))
r.Post("/v1/documents/{slug}/read", markReadHandler(deps.DB))
r.Post("/v1/documents/{slug}/unread", markUnreadHandler(deps.DB))
})
}
// writeJSON writes a JSON response with the given status code.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// writeError writes a standard error response.
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}

View File

@@ -0,0 +1,178 @@
package webserver
import (
"crypto/rand"
"fmt"
"html/template"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"git.wntrmute.dev/mc/mcdsl/auth"
"git.wntrmute.dev/mc/mcdsl/csrf"
"git.wntrmute.dev/mc/mcdsl/web"
mcqweb "git.wntrmute.dev/mc/mcq/web"
"git.wntrmute.dev/mc/mcq/internal/db"
"git.wntrmute.dev/mc/mcq/internal/render"
)
const cookieName = "mcq_session"
// Config holds webserver-specific configuration.
type Config struct {
ServiceName string
Tags []string
}
// Server is the MCQ web UI server.
type Server struct {
db *db.DB
auth *auth.Authenticator
csrf *csrf.Protect
render *render.Renderer
logger *slog.Logger
config Config
}
// New creates a web UI server.
func New(cfg Config, database *db.DB, authenticator *auth.Authenticator, logger *slog.Logger) (*Server, error) {
csrfSecret := make([]byte, 32)
if _, err := rand.Read(csrfSecret); err != nil {
return nil, fmt.Errorf("generate CSRF secret: %w", err)
}
csrfProtect := csrf.New(csrfSecret, "_csrf", "csrf_token")
return &Server{
db: database,
auth: authenticator,
csrf: csrfProtect,
render: render.New(),
logger: logger,
config: cfg,
}, nil
}
// RegisterRoutes adds web UI routes to the given router.
func (s *Server) RegisterRoutes(r chi.Router) {
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.
r.Group(func(r chi.Router) {
r.Use(web.RequireAuth(s.auth, cookieName, "/login"))
r.Use(s.csrf.Middleware)
r.Get("/", s.handleList)
r.Get("/d/{slug}", s.handleRead)
r.Post("/d/{slug}/read", s.handleMarkRead)
r.Post("/d/{slug}/unread", s.handleMarkUnread)
r.Post("/logout", s.handleLogout)
})
}
type pageData struct {
Username string
Error string
Title string
Content any
}
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
web.RenderTemplate(w, mcqweb.FS, "login.html", pageData{}, s.csrf.TemplateFunc(w))
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username")
password := r.FormValue("password")
totpCode := r.FormValue("totp_code")
token, _, err := s.auth.Login(username, password, totpCode)
if err != nil {
web.RenderTemplate(w, mcqweb.FS, "login.html", pageData{Error: "Invalid credentials"}, s.csrf.TemplateFunc(w))
return
}
web.SetSessionCookie(w, cookieName, token)
http.Redirect(w, r, "/", http.StatusFound)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
token := web.GetSessionToken(r, cookieName)
if token != "" {
_ = s.auth.Logout(token)
}
web.ClearSessionCookie(w, cookieName)
http.Redirect(w, r, "/login", http.StatusFound)
}
type listData struct {
Username string
Documents []db.Document
}
func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
info := auth.TokenInfoFromContext(r.Context())
docs, err := s.db.ListDocuments()
if err != nil {
s.logger.Error("failed to list documents", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if docs == nil {
docs = []db.Document{}
}
web.RenderTemplate(w, mcqweb.FS, "list.html", listData{
Username: info.Username,
Documents: docs,
}, s.csrf.TemplateFunc(w))
}
type readData struct {
Username string
Doc db.Document
HTML template.HTML
}
func (s *Server) handleRead(w http.ResponseWriter, r *http.Request) {
info := auth.TokenInfoFromContext(r.Context())
slug := chi.URLParam(r, "slug")
doc, err := s.db.GetDocument(slug)
if err != nil {
http.NotFound(w, r)
return
}
html, err := s.render.Render([]byte(doc.Body))
if err != nil {
s.logger.Error("failed to render markdown", "slug", slug, "error", err)
http.Error(w, "render error", http.StatusInternalServerError)
return
}
web.RenderTemplate(w, mcqweb.FS, "read.html", readData{
Username: info.Username,
Doc: *doc,
HTML: template.HTML(html), //nolint:gosec // markdown rendered by goldmark, not user-controlled injection
}, s.csrf.TemplateFunc(w))
}
func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
if _, err := s.db.MarkRead(slug); err != nil {
s.logger.Error("failed to mark read", "slug", slug, "error", err)
}
http.Redirect(w, r, "/", http.StatusFound)
}
func (s *Server) handleMarkUnread(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
if _, err := s.db.MarkUnread(slug); err != nil {
s.logger.Error("failed to mark unread", "slug", slug, "error", err)
}
http.Redirect(w, r, "/", http.StatusFound)
}

90
proto/mcq/v1/mcq.proto Normal file
View File

@@ -0,0 +1,90 @@
syntax = "proto3";
package mcq.v1;
option go_package = "git.wntrmute.dev/mc/mcq/gen/mcq/v1;mcqv1";
import "google/protobuf/timestamp.proto";
// DocumentService manages queued documents for reading.
service DocumentService {
rpc ListDocuments(ListDocumentsRequest) returns (ListDocumentsResponse);
rpc GetDocument(GetDocumentRequest) returns (Document);
rpc PutDocument(PutDocumentRequest) returns (Document);
rpc DeleteDocument(DeleteDocumentRequest) returns (DeleteDocumentResponse);
rpc MarkRead(MarkReadRequest) returns (Document);
rpc MarkUnread(MarkUnreadRequest) returns (Document);
}
// AuthService handles MCIAS login and logout.
service AuthService {
rpc Login(LoginRequest) returns (LoginResponse);
rpc Logout(LogoutRequest) returns (LogoutResponse);
}
// AdminService provides health checks.
service AdminService {
rpc Health(HealthRequest) returns (HealthResponse);
}
message Document {
int64 id = 1;
string slug = 2;
string title = 3;
string body = 4;
string pushed_by = 5;
google.protobuf.Timestamp pushed_at = 6;
bool read = 7;
}
message ListDocumentsRequest {}
message ListDocumentsResponse {
repeated Document documents = 1;
}
message GetDocumentRequest {
string slug = 1;
}
message PutDocumentRequest {
string slug = 1;
string title = 2;
string body = 3;
}
message DeleteDocumentRequest {
string slug = 1;
}
message DeleteDocumentResponse {}
message MarkReadRequest {
string slug = 1;
}
message MarkUnreadRequest {
string slug = 1;
}
message LoginRequest {
string username = 1;
string password = 2; // security: never logged
string totp_code = 3;
}
message LoginResponse {
string token = 1; // security: never logged
}
message LogoutRequest {
string token = 1; // security: never logged
}
message LogoutResponse {}
message HealthRequest {}
message HealthResponse {
string status = 1;
}

6
web/embed.go Normal file
View File

@@ -0,0 +1,6 @@
package web
import "embed"
//go:embed templates static
var FS embed.FS

1
web/static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

368
web/static/style.css Normal file
View File

@@ -0,0 +1,368 @@
/* mcq — Nord dark theme, mobile-first reading UI */
/* ===========================
Colour tokens (Nord palette)
=========================== */
:root {
--n0: #2E3440;
--n1: #3B4252;
--n2: #434C5E;
--n3: #4C566A;
--s0: #D8DEE9;
--s1: #E5E9F0;
--s2: #ECEFF4;
--f0: #8FBCBB;
--f1: #88C0D0;
--f2: #81A1C1;
--f3: #5E81AC;
--red: #BF616A;
--green: #A3BE8C;
--yellow: #EBCB8B;
}
/* ===========================
Reset
=========================== */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
html { font-size: 16px; }
/* ===========================
Base
=========================== */
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
background: var(--n0);
color: var(--s0);
line-height: 1.6;
min-height: 100vh;
}
a { color: var(--f1); text-decoration: none; }
a:hover { color: var(--f0); text-decoration: underline; }
p { margin-bottom: 0.875rem; }
h2 { font-size: 1.375rem; font-weight: 600; color: var(--s2); margin-bottom: 0.25rem; }
code {
font-family: "SF Mono", "Cascadia Code", "Fira Code", Consolas, monospace;
font-size: 0.8125rem;
color: var(--f0);
background: var(--n2);
padding: 0.125rem 0.375rem;
border-radius: 3px;
}
/* ===========================
Top navigation
=========================== */
.topnav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1rem;
height: 48px;
background: var(--n1);
border-bottom: 1px solid var(--n3);
position: sticky;
top: 0;
z-index: 100;
}
.topnav-brand {
font-size: 1rem;
font-weight: 700;
color: var(--s2);
text-decoration: none;
letter-spacing: 0.04em;
}
.topnav-brand:hover { color: var(--f1); text-decoration: none; }
.topnav-right {
display: flex;
align-items: center;
gap: 0.75rem;
}
.topnav-user {
font-size: 0.8125rem;
color: var(--s1);
}
/* ===========================
Page containers
=========================== */
.page-container {
max-width: 720px;
margin: 0 auto;
padding: 1.5rem 1rem;
}
.auth-container {
max-width: 420px;
margin: 5rem auto 2rem;
padding: 0 1rem;
}
/* ===========================
Auth pages
=========================== */
.auth-header {
text-align: center;
margin-bottom: 1.75rem;
}
.auth-header .brand {
font-size: 1.5rem;
font-weight: 700;
color: var(--s2);
letter-spacing: 0.04em;
}
.auth-header .tagline {
font-size: 0.6875rem;
color: var(--f2);
text-transform: uppercase;
letter-spacing: 0.12em;
margin-top: 0.25rem;
}
/* ===========================
Cards
=========================== */
.card {
background: var(--n1);
border: 1px solid var(--n3);
border-radius: 6px;
padding: 1.25rem;
margin-bottom: 1rem;
}
.card:last-child { margin-bottom: 0; }
/* ===========================
Alerts
=========================== */
.error {
background: rgba(191, 97, 106, 0.12);
color: #e07c82;
border: 1px solid rgba(191, 97, 106, 0.3);
padding: 0.75rem 1rem;
border-radius: 4px;
margin-bottom: 1rem;
font-size: 0.875rem;
}
/* ===========================
Buttons
=========================== */
button, .btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0.5rem 1.25rem;
font-size: 0.875rem;
font-weight: 600;
font-family: inherit;
border: 1px solid var(--f3);
border-radius: 4px;
cursor: pointer;
text-decoration: none;
white-space: nowrap;
transition: background 0.12s, border-color 0.12s, color 0.12s;
line-height: 1.4;
background: var(--f3);
color: var(--s2);
}
button:hover, .btn:hover {
background: var(--f2);
border-color: var(--f2);
text-decoration: none;
color: var(--s2);
}
.btn-ghost {
background: transparent;
color: var(--s0);
border-color: var(--n3);
}
.btn-ghost:hover {
background: var(--n2);
color: var(--s1);
border-color: var(--n3);
text-decoration: none;
}
.btn-sm {
padding: 0.25rem 0.75rem;
font-size: 0.75rem;
}
/* ===========================
Forms
=========================== */
.form-group { margin-bottom: 1rem; }
.form-group label {
display: block;
font-size: 0.6875rem;
font-weight: 700;
color: var(--s0);
margin-bottom: 0.375rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.form-group input {
width: 100%;
padding: 0.5rem 0.75rem;
background: var(--n0);
border: 1px solid var(--n3);
border-radius: 4px;
color: var(--s1);
font-size: 0.9375rem;
font-family: inherit;
transition: border-color 0.12s, box-shadow 0.12s;
-webkit-appearance: none;
appearance: none;
}
.form-group input:focus {
outline: none;
border-color: var(--f3);
box-shadow: 0 0 0 3px rgba(94, 129, 172, 0.2);
}
.form-group input::placeholder { color: var(--n3); }
.form-actions { margin-top: 0.25rem; }
/* ===========================
Document list
=========================== */
.doc-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 1rem;
}
.doc-item {
display: block;
background: var(--n1);
border: 1px solid var(--n3);
border-radius: 6px;
padding: 1rem 1.25rem;
text-decoration: none;
transition: border-color 0.12s, background 0.12s;
}
.doc-item:hover {
border-color: var(--f3);
background: var(--n2);
text-decoration: none;
}
.doc-item.doc-read {
opacity: 0.6;
}
.doc-title {
font-size: 1rem;
font-weight: 600;
color: var(--s2);
margin-bottom: 0.25rem;
}
.doc-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
font-size: 0.75rem;
color: var(--n3);
}
.doc-badge {
display: inline-block;
padding: 0.0625rem 0.375rem;
border-radius: 3px;
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.doc-badge.unread {
background: rgba(94, 129, 172, 0.2);
color: var(--f1);
border: 1px solid rgba(94, 129, 172, 0.35);
}
.doc-badge.read {
background: rgba(163, 190, 140, 0.15);
color: var(--green);
border: 1px solid rgba(163, 190, 140, 0.3);
}
/* ===========================
Read view
=========================== */
.read-header {
margin-bottom: 1.25rem;
}
.read-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
font-size: 0.75rem;
color: var(--n3);
margin-top: 0.25rem;
margin-bottom: 0.75rem;
}
.read-actions {
display: flex;
gap: 0.5rem;
}
/* ===========================
Markdown body
=========================== */
.markdown-body {
font-size: 0.9375rem;
line-height: 1.7;
}
.markdown-body h1 { font-size: 1.5rem; font-weight: 700; color: var(--s2); margin: 1.5rem 0 0.75rem; }
.markdown-body h2 { font-size: 1.25rem; font-weight: 600; color: var(--s2); margin: 1.25rem 0 0.5rem; }
.markdown-body h3 { font-size: 1.0625rem; font-weight: 600; color: var(--s1); margin: 1rem 0 0.5rem; }
.markdown-body h4 { font-size: 0.9375rem; font-weight: 600; color: var(--s1); margin: 0.75rem 0 0.375rem; }
.markdown-body p { margin-bottom: 0.75rem; }
.markdown-body ul, .markdown-body ol {
margin-bottom: 0.75rem;
padding-left: 1.5rem;
}
.markdown-body li { margin-bottom: 0.25rem; }
.markdown-body pre {
background: var(--n0);
border: 1px solid var(--n3);
border-radius: 4px;
padding: 0.875rem 1rem;
overflow-x: auto;
margin-bottom: 0.75rem;
font-size: 0.8125rem;
line-height: 1.5;
}
.markdown-body pre code {
background: none;
padding: 0;
font-size: inherit;
color: var(--s0);
}
.markdown-body blockquote {
border-left: 3px solid var(--f3);
padding-left: 1rem;
color: var(--s0);
margin-bottom: 0.75rem;
}
.markdown-body table {
width: 100%;
border-collapse: collapse;
margin-bottom: 0.75rem;
font-size: 0.875rem;
}
.markdown-body th, .markdown-body td {
padding: 0.5rem 0.75rem;
border: 1px solid var(--n3);
text-align: left;
}
.markdown-body th {
background: var(--n2);
font-weight: 600;
color: var(--s2);
}
.markdown-body hr {
border: none;
border-top: 1px solid var(--n3);
margin: 1.25rem 0;
}
.markdown-body a { color: var(--f1); }
.markdown-body a:hover { color: var(--f0); }
.markdown-body img {
max-width: 100%;
height: auto;
}
.markdown-body strong { color: var(--s2); }

26
web/templates/layout.html Normal file
View File

@@ -0,0 +1,26 @@
{{define "layout"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>mcq{{block "title" .}}{{end}}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<nav class="topnav">
<a href="/" class="topnav-brand">mcq</a>
{{if .Username}}
<div class="topnav-right">
<span class="topnav-user">{{.Username}}</span>
<form method="POST" action="/logout" style="margin:0">
{{csrfField}}
<button type="submit" class="btn-ghost btn">Logout</button>
</form>
</div>
{{end}}
</nav>
<div class="{{block "container-class" .}}page-container{{end}}">
{{template "content" .}}
</div>
</body>
</html>{{end}}

22
web/templates/list.html Normal file
View File

@@ -0,0 +1,22 @@
{{define "title"}} — Queue{{end}}
{{define "content"}}
<h2>Reading Queue</h2>
{{if not .Documents}}
<div class="card">
<p>No documents in queue.</p>
</div>
{{else}}
<div class="doc-list">
{{range .Documents}}
<a href="/d/{{.Slug}}" class="doc-item {{if .Read}}doc-read{{end}}">
<div class="doc-title">{{.Title}}</div>
<div class="doc-meta">
<span>{{.PushedBy}}</span>
<span>{{.PushedAt}}</span>
{{if .Read}}<span class="doc-badge read">read</span>{{else}}<span class="doc-badge unread">unread</span>{{end}}
</div>
</a>
{{end}}
</div>
{{end}}
{{end}}

29
web/templates/login.html Normal file
View File

@@ -0,0 +1,29 @@
{{define "title"}} — Login{{end}}
{{define "container-class"}}auth-container{{end}}
{{define "content"}}
<div class="auth-header">
<div class="brand">mcq</div>
<div class="tagline">Reading Queue</div>
</div>
<div class="card">
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
<form method="POST" action="/login">
{{csrfField}}
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<div class="form-group">
<label for="totp_code">TOTP Code (optional)</label>
<input type="text" id="totp_code" name="totp_code" inputmode="numeric" autocomplete="one-time-code">
</div>
<div class="form-actions">
<button type="submit" class="btn">Login</button>
</div>
</form>
</div>
{{end}}

27
web/templates/read.html Normal file
View File

@@ -0,0 +1,27 @@
{{define "title"}} — {{.Doc.Title}}{{end}}
{{define "content"}}
<div class="read-header">
<h2>{{.Doc.Title}}</h2>
<div class="read-meta">
<span>Pushed by {{.Doc.PushedBy}}</span>
<span>{{.Doc.PushedAt}}</span>
</div>
<div class="read-actions">
{{if .Doc.Read}}
<form method="POST" action="/d/{{.Doc.Slug}}/unread" style="display:inline">
{{csrfField}}
<button type="submit" class="btn-ghost btn btn-sm">Mark unread</button>
</form>
{{else}}
<form method="POST" action="/d/{{.Doc.Slug}}/read" style="display:inline">
{{csrfField}}
<button type="submit" class="btn-ghost btn btn-sm">Mark read</button>
</form>
{{end}}
<a href="/" class="btn-ghost btn btn-sm">Back to queue</a>
</div>
</div>
<div class="card markdown-body">
{{.HTML}}
</div>
{{end}}