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>
141 lines
3.9 KiB
Go
141 lines
3.9 KiB
Go
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)
|
|
}
|