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

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()
}