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:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/mcq
|
||||
/srv/
|
||||
90
ARCHITECTURE.md
Normal file
90
ARCHITECTURE.md
Normal 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
69
CLAUDE.md
Normal 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
45
Makefile
Normal 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
9
buf.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
version: v2
|
||||
modules:
|
||||
- path: proto
|
||||
lint:
|
||||
use:
|
||||
- STANDARD
|
||||
breaking:
|
||||
use:
|
||||
- FILE
|
||||
23
cmd/mcq/main.go
Normal file
23
cmd/mcq/main.go
Normal 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
168
cmd/mcq/server.go
Normal 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
|
||||
}
|
||||
}
|
||||
16
deploy/examples/mcq.toml.example
Normal file
16
deploy/examples/mcq.toml.example
Normal 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"
|
||||
25
deploy/systemd/mcq.service
Normal file
25
deploy/systemd/mcq.service
Normal 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
869
gen/mcq/v1/mcq.pb.go
Normal 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
565
gen/mcq/v1/mcq_grpc.pb.go
Normal 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
34
go.mod
Normal 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
126
go.sum
Normal 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
23
internal/db/db.go
Normal 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
121
internal/db/documents.go
Normal 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)
|
||||
}
|
||||
145
internal/db/documents_test.go
Normal file
145
internal/db/documents_test.go
Normal 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
28
internal/db/migrate.go
Normal 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)
|
||||
}
|
||||
20
internal/grpcserver/admin.go
Normal file
20
internal/grpcserver/admin.go
Normal 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
|
||||
}
|
||||
38
internal/grpcserver/auth_handler.go
Normal file
38
internal/grpcserver/auth_handler.go
Normal 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
|
||||
}
|
||||
140
internal/grpcserver/documents.go
Normal file
140
internal/grpcserver/documents.go
Normal 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)
|
||||
}
|
||||
40
internal/grpcserver/interceptors.go
Normal file
40
internal/grpcserver/interceptors.go
Normal 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{}
|
||||
}
|
||||
49
internal/grpcserver/server.go
Normal file
49
internal/grpcserver/server.go
Normal 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
50
internal/render/render.go
Normal 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
62
internal/server/auth.go
Normal 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)
|
||||
}
|
||||
}
|
||||
131
internal/server/documents.go
Normal file
131
internal/server/documents.go
Normal 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)
|
||||
}
|
||||
}
|
||||
84
internal/server/middleware.go
Normal file
84
internal/server/middleware.go
Normal 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
54
internal/server/routes.go
Normal 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})
|
||||
}
|
||||
178
internal/webserver/server.go
Normal file
178
internal/webserver/server.go
Normal 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
90
proto/mcq/v1/mcq.proto
Normal 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
6
web/embed.go
Normal 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
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
368
web/static/style.css
Normal 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
26
web/templates/layout.html
Normal 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
22
web/templates/list.html
Normal 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
29
web/templates/login.html
Normal 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
27
web/templates/read.html
Normal 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}}
|
||||
Reference in New Issue
Block a user