Implement Phase 4: gRPC sync service

- Proto definitions (engpad.v1.EngPadSync) with 6 RPCs
- Generated Go gRPC code
- Auth interceptor: username/password from metadata
- SyncNotebook: upsert with full page/stroke replacement in a tx
- DeleteNotebook, ListNotebooks handlers
- Share link RPCs: CreateShareLink, RevokeShareLink, ListShareLinks
- Share link token management (32-byte random, optional expiry)
- gRPC server with TLS 1.3

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 19:52:47 -07:00
parent 846a6fe42d
commit 7d4e52ae92
10 changed files with 1912 additions and 2 deletions

1031
gen/engpad/v1/sync.pb.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,311 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v3.20.3
// source: engpad/v1/sync.proto
package engpadv1
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 (
EngPadSync_SyncNotebook_FullMethodName = "/engpad.v1.EngPadSync/SyncNotebook"
EngPadSync_DeleteNotebook_FullMethodName = "/engpad.v1.EngPadSync/DeleteNotebook"
EngPadSync_ListNotebooks_FullMethodName = "/engpad.v1.EngPadSync/ListNotebooks"
EngPadSync_CreateShareLink_FullMethodName = "/engpad.v1.EngPadSync/CreateShareLink"
EngPadSync_RevokeShareLink_FullMethodName = "/engpad.v1.EngPadSync/RevokeShareLink"
EngPadSync_ListShareLinks_FullMethodName = "/engpad.v1.EngPadSync/ListShareLinks"
)
// EngPadSyncClient is the client API for EngPadSync 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.
type EngPadSyncClient interface {
SyncNotebook(ctx context.Context, in *SyncNotebookRequest, opts ...grpc.CallOption) (*SyncNotebookResponse, error)
DeleteNotebook(ctx context.Context, in *DeleteNotebookRequest, opts ...grpc.CallOption) (*DeleteNotebookResponse, error)
ListNotebooks(ctx context.Context, in *ListNotebooksRequest, opts ...grpc.CallOption) (*ListNotebooksResponse, error)
CreateShareLink(ctx context.Context, in *CreateShareLinkRequest, opts ...grpc.CallOption) (*CreateShareLinkResponse, error)
RevokeShareLink(ctx context.Context, in *RevokeShareLinkRequest, opts ...grpc.CallOption) (*RevokeShareLinkResponse, error)
ListShareLinks(ctx context.Context, in *ListShareLinksRequest, opts ...grpc.CallOption) (*ListShareLinksResponse, error)
}
type engPadSyncClient struct {
cc grpc.ClientConnInterface
}
func NewEngPadSyncClient(cc grpc.ClientConnInterface) EngPadSyncClient {
return &engPadSyncClient{cc}
}
func (c *engPadSyncClient) SyncNotebook(ctx context.Context, in *SyncNotebookRequest, opts ...grpc.CallOption) (*SyncNotebookResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SyncNotebookResponse)
err := c.cc.Invoke(ctx, EngPadSync_SyncNotebook_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *engPadSyncClient) DeleteNotebook(ctx context.Context, in *DeleteNotebookRequest, opts ...grpc.CallOption) (*DeleteNotebookResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DeleteNotebookResponse)
err := c.cc.Invoke(ctx, EngPadSync_DeleteNotebook_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *engPadSyncClient) ListNotebooks(ctx context.Context, in *ListNotebooksRequest, opts ...grpc.CallOption) (*ListNotebooksResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListNotebooksResponse)
err := c.cc.Invoke(ctx, EngPadSync_ListNotebooks_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *engPadSyncClient) CreateShareLink(ctx context.Context, in *CreateShareLinkRequest, opts ...grpc.CallOption) (*CreateShareLinkResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CreateShareLinkResponse)
err := c.cc.Invoke(ctx, EngPadSync_CreateShareLink_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *engPadSyncClient) RevokeShareLink(ctx context.Context, in *RevokeShareLinkRequest, opts ...grpc.CallOption) (*RevokeShareLinkResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RevokeShareLinkResponse)
err := c.cc.Invoke(ctx, EngPadSync_RevokeShareLink_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *engPadSyncClient) ListShareLinks(ctx context.Context, in *ListShareLinksRequest, opts ...grpc.CallOption) (*ListShareLinksResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListShareLinksResponse)
err := c.cc.Invoke(ctx, EngPadSync_ListShareLinks_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// EngPadSyncServer is the server API for EngPadSync service.
// All implementations must embed UnimplementedEngPadSyncServer
// for forward compatibility.
type EngPadSyncServer interface {
SyncNotebook(context.Context, *SyncNotebookRequest) (*SyncNotebookResponse, error)
DeleteNotebook(context.Context, *DeleteNotebookRequest) (*DeleteNotebookResponse, error)
ListNotebooks(context.Context, *ListNotebooksRequest) (*ListNotebooksResponse, error)
CreateShareLink(context.Context, *CreateShareLinkRequest) (*CreateShareLinkResponse, error)
RevokeShareLink(context.Context, *RevokeShareLinkRequest) (*RevokeShareLinkResponse, error)
ListShareLinks(context.Context, *ListShareLinksRequest) (*ListShareLinksResponse, error)
mustEmbedUnimplementedEngPadSyncServer()
}
// UnimplementedEngPadSyncServer 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 UnimplementedEngPadSyncServer struct{}
func (UnimplementedEngPadSyncServer) SyncNotebook(context.Context, *SyncNotebookRequest) (*SyncNotebookResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SyncNotebook not implemented")
}
func (UnimplementedEngPadSyncServer) DeleteNotebook(context.Context, *DeleteNotebookRequest) (*DeleteNotebookResponse, error) {
return nil, status.Error(codes.Unimplemented, "method DeleteNotebook not implemented")
}
func (UnimplementedEngPadSyncServer) ListNotebooks(context.Context, *ListNotebooksRequest) (*ListNotebooksResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListNotebooks not implemented")
}
func (UnimplementedEngPadSyncServer) CreateShareLink(context.Context, *CreateShareLinkRequest) (*CreateShareLinkResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CreateShareLink not implemented")
}
func (UnimplementedEngPadSyncServer) RevokeShareLink(context.Context, *RevokeShareLinkRequest) (*RevokeShareLinkResponse, error) {
return nil, status.Error(codes.Unimplemented, "method RevokeShareLink not implemented")
}
func (UnimplementedEngPadSyncServer) ListShareLinks(context.Context, *ListShareLinksRequest) (*ListShareLinksResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListShareLinks not implemented")
}
func (UnimplementedEngPadSyncServer) mustEmbedUnimplementedEngPadSyncServer() {}
func (UnimplementedEngPadSyncServer) testEmbeddedByValue() {}
// UnsafeEngPadSyncServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to EngPadSyncServer will
// result in compilation errors.
type UnsafeEngPadSyncServer interface {
mustEmbedUnimplementedEngPadSyncServer()
}
func RegisterEngPadSyncServer(s grpc.ServiceRegistrar, srv EngPadSyncServer) {
// If the following call panics, it indicates UnimplementedEngPadSyncServer 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(&EngPadSync_ServiceDesc, srv)
}
func _EngPadSync_SyncNotebook_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SyncNotebookRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(EngPadSyncServer).SyncNotebook(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: EngPadSync_SyncNotebook_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(EngPadSyncServer).SyncNotebook(ctx, req.(*SyncNotebookRequest))
}
return interceptor(ctx, in, info, handler)
}
func _EngPadSync_DeleteNotebook_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteNotebookRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(EngPadSyncServer).DeleteNotebook(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: EngPadSync_DeleteNotebook_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(EngPadSyncServer).DeleteNotebook(ctx, req.(*DeleteNotebookRequest))
}
return interceptor(ctx, in, info, handler)
}
func _EngPadSync_ListNotebooks_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListNotebooksRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(EngPadSyncServer).ListNotebooks(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: EngPadSync_ListNotebooks_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(EngPadSyncServer).ListNotebooks(ctx, req.(*ListNotebooksRequest))
}
return interceptor(ctx, in, info, handler)
}
func _EngPadSync_CreateShareLink_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreateShareLinkRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(EngPadSyncServer).CreateShareLink(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: EngPadSync_CreateShareLink_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(EngPadSyncServer).CreateShareLink(ctx, req.(*CreateShareLinkRequest))
}
return interceptor(ctx, in, info, handler)
}
func _EngPadSync_RevokeShareLink_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RevokeShareLinkRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(EngPadSyncServer).RevokeShareLink(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: EngPadSync_RevokeShareLink_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(EngPadSyncServer).RevokeShareLink(ctx, req.(*RevokeShareLinkRequest))
}
return interceptor(ctx, in, info, handler)
}
func _EngPadSync_ListShareLinks_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListShareLinksRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(EngPadSyncServer).ListShareLinks(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: EngPadSync_ListShareLinks_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(EngPadSyncServer).ListShareLinks(ctx, req.(*ListShareLinksRequest))
}
return interceptor(ctx, in, info, handler)
}
// EngPadSync_ServiceDesc is the grpc.ServiceDesc for EngPadSync service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var EngPadSync_ServiceDesc = grpc.ServiceDesc{
ServiceName: "engpad.v1.EngPadSync",
HandlerType: (*EngPadSyncServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "SyncNotebook",
Handler: _EngPadSync_SyncNotebook_Handler,
},
{
MethodName: "DeleteNotebook",
Handler: _EngPadSync_DeleteNotebook_Handler,
},
{
MethodName: "ListNotebooks",
Handler: _EngPadSync_ListNotebooks_Handler,
},
{
MethodName: "CreateShareLink",
Handler: _EngPadSync_CreateShareLink_Handler,
},
{
MethodName: "RevokeShareLink",
Handler: _EngPadSync_RevokeShareLink_Handler,
},
{
MethodName: "ListShareLinks",
Handler: _EngPadSync_ListShareLinks_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "engpad/v1/sync.proto",
}

5
go.mod
View File

@@ -7,6 +7,8 @@ require (
github.com/spf13/cobra v1.10.2
golang.org/x/crypto v0.49.0
golang.org/x/term v0.41.0
google.golang.org/grpc v1.79.3
google.golang.org/protobuf v1.36.11
modernc.org/sqlite v1.47.0
)
@@ -18,7 +20,10 @@ require (
github.com/ncruces/go-strftime v1.0.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.51.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.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

38
go.sum
View File

@@ -1,6 +1,16 @@
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/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-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=
@@ -22,20 +32,44 @@ 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=
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/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
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/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
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/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
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=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=

View File

@@ -0,0 +1,46 @@
package grpcserver
import (
"context"
"database/sql"
"git.wntrmute.dev/kyle/eng-pad-server/internal/auth"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
type contextKey string
const userIDKey contextKey = "user_id"
// UserIDFromContext extracts the authenticated user ID from the context.
func UserIDFromContext(ctx context.Context) (int64, bool) {
id, ok := ctx.Value(userIDKey).(int64)
return id, ok
}
// AuthInterceptor verifies username/password from gRPC metadata.
func AuthInterceptor(database *sql.DB) grpc.UnaryServerInterceptor {
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "missing metadata")
}
usernames := md.Get("username")
passwords := md.Get("password")
if len(usernames) == 0 || len(passwords) == 0 {
return nil, status.Error(codes.Unauthenticated, "missing credentials")
}
userID, err := auth.AuthenticateUser(database, usernames[0], passwords[0])
if err != nil {
return nil, status.Error(codes.Unauthenticated, "invalid credentials")
}
ctx = context.WithValue(ctx, userIDKey, userID)
return handler(ctx, req)
}
}

View File

@@ -0,0 +1,48 @@
package grpcserver
import (
"crypto/tls"
"database/sql"
"fmt"
"net"
pb "git.wntrmute.dev/kyle/eng-pad-server/gen/engpad/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
type Config struct {
Addr string
TLSCert string
TLSKey string
DB *sql.DB
BaseURL string
}
func Start(cfg Config) error {
cert, err := tls.LoadX509KeyPair(cfg.TLSCert, cfg.TLSKey)
if err != nil {
return fmt.Errorf("load TLS cert: %w", err)
}
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS13,
}
lis, err := net.Listen("tcp", cfg.Addr)
if err != nil {
return fmt.Errorf("listen %s: %w", cfg.Addr, err)
}
srv := grpc.NewServer(
grpc.Creds(credentials.NewTLS(tlsConfig)),
grpc.UnaryInterceptor(AuthInterceptor(cfg.DB)),
)
syncSvc := &SyncService{DB: cfg.DB, BaseURL: cfg.BaseURL}
pb.RegisterEngPadSyncServer(srv, syncSvc)
fmt.Printf("gRPC listening on %s\n", cfg.Addr)
return srv.Serve(lis)
}

View File

@@ -0,0 +1,94 @@
package grpcserver
import (
"context"
"database/sql"
"time"
pb "git.wntrmute.dev/kyle/eng-pad-server/gen/engpad/v1"
"git.wntrmute.dev/kyle/eng-pad-server/internal/share"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
)
func (s *SyncService) CreateShareLink(ctx context.Context, req *pb.CreateShareLinkRequest) (*pb.CreateShareLinkResponse, error) {
userID, ok := UserIDFromContext(ctx)
if !ok {
return nil, status.Error(codes.Internal, "missing user context")
}
// Verify notebook belongs to user
var notebookID int64
err := s.DB.QueryRowContext(ctx,
"SELECT id FROM notebooks WHERE user_id = ? AND remote_id = ?",
userID, req.NotebookId,
).Scan(&notebookID)
if err == sql.ErrNoRows {
return nil, status.Error(codes.NotFound, "notebook not found")
} else if err != nil {
return nil, status.Errorf(codes.Internal, "query: %v", err)
}
var expiry time.Duration
if req.ExpiresInSeconds > 0 {
expiry = time.Duration(req.ExpiresInSeconds) * time.Second
}
token, expiresAt, err := share.CreateLink(s.DB, notebookID, expiry, s.BaseURL)
if err != nil {
return nil, status.Errorf(codes.Internal, "create link: %v", err)
}
resp := &pb.CreateShareLinkResponse{
Token: token,
Url: s.BaseURL + "/s/" + token,
}
if expiresAt != nil {
resp.ExpiresAt = timestamppb.New(*expiresAt)
}
return resp, nil
}
func (s *SyncService) RevokeShareLink(ctx context.Context, req *pb.RevokeShareLinkRequest) (*pb.RevokeShareLinkResponse, error) {
if err := share.RevokeLink(s.DB, req.Token); err != nil {
return nil, status.Errorf(codes.Internal, "revoke: %v", err)
}
return &pb.RevokeShareLinkResponse{}, nil
}
func (s *SyncService) ListShareLinks(ctx context.Context, req *pb.ListShareLinksRequest) (*pb.ListShareLinksResponse, error) {
userID, ok := UserIDFromContext(ctx)
if !ok {
return nil, status.Error(codes.Internal, "missing user context")
}
var notebookID int64
err := s.DB.QueryRowContext(ctx,
"SELECT id FROM notebooks WHERE user_id = ? AND remote_id = ?",
userID, req.NotebookId,
).Scan(&notebookID)
if err != nil {
return nil, status.Error(codes.NotFound, "notebook not found")
}
links, err := share.ListLinks(s.DB, notebookID, s.BaseURL)
if err != nil {
return nil, status.Errorf(codes.Internal, "list: %v", err)
}
var pbLinks []*pb.ShareLinkInfo
for _, l := range links {
pbl := &pb.ShareLinkInfo{
Token: l.Token,
Url: l.URL,
CreatedAt: timestamppb.New(l.CreatedAt),
}
if l.ExpiresAt != nil {
pbl.ExpiresAt = timestamppb.New(*l.ExpiresAt)
}
pbLinks = append(pbLinks, pbl)
}
return &pb.ListShareLinksResponse{Links: pbLinks}, nil
}

142
internal/grpcserver/sync.go Normal file
View File

@@ -0,0 +1,142 @@
package grpcserver
import (
"context"
"database/sql"
"time"
pb "git.wntrmute.dev/kyle/eng-pad-server/gen/engpad/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
)
type SyncService struct {
pb.UnimplementedEngPadSyncServer
DB *sql.DB
BaseURL string
}
func (s *SyncService) SyncNotebook(ctx context.Context, req *pb.SyncNotebookRequest) (*pb.SyncNotebookResponse, error) {
userID, ok := UserIDFromContext(ctx)
if !ok {
return nil, status.Error(codes.Internal, "missing user context")
}
tx, err := s.DB.BeginTx(ctx, nil)
if err != nil {
return nil, status.Errorf(codes.Internal, "begin tx: %v", err)
}
defer func() { _ = tx.Rollback() }()
now := time.Now().UnixMilli()
// Upsert notebook
var notebookID int64
err = tx.QueryRowContext(ctx,
"SELECT id FROM notebooks WHERE user_id = ? AND remote_id = ?",
userID, req.NotebookId,
).Scan(&notebookID)
if err == sql.ErrNoRows {
res, err := tx.ExecContext(ctx,
"INSERT INTO notebooks (user_id, remote_id, title, page_size, synced_at) VALUES (?, ?, ?, ?, ?)",
userID, req.NotebookId, req.Title, req.PageSize, now,
)
if err != nil {
return nil, status.Errorf(codes.Internal, "insert notebook: %v", err)
}
notebookID, _ = res.LastInsertId()
} else if err != nil {
return nil, status.Errorf(codes.Internal, "query notebook: %v", err)
} else {
// Update existing — delete all pages (cascade deletes strokes)
if _, err := tx.ExecContext(ctx, "DELETE FROM pages WHERE notebook_id = ?", notebookID); err != nil {
return nil, status.Errorf(codes.Internal, "delete pages: %v", err)
}
if _, err := tx.ExecContext(ctx,
"UPDATE notebooks SET title = ?, page_size = ?, synced_at = ? WHERE id = ?",
req.Title, req.PageSize, now, notebookID,
); err != nil {
return nil, status.Errorf(codes.Internal, "update notebook: %v", err)
}
}
// Insert pages and strokes
for _, page := range req.Pages {
res, err := tx.ExecContext(ctx,
"INSERT INTO pages (notebook_id, remote_id, page_number) VALUES (?, ?, ?)",
notebookID, page.PageId, page.PageNumber,
)
if err != nil {
return nil, status.Errorf(codes.Internal, "insert page: %v", err)
}
pageID, _ := res.LastInsertId()
for _, stroke := range page.Strokes {
if _, err := tx.ExecContext(ctx,
"INSERT INTO strokes (page_id, pen_size, color, style, point_data, stroke_order) VALUES (?, ?, ?, ?, ?, ?)",
pageID, stroke.PenSize, stroke.Color, stroke.Style, stroke.PointData, stroke.StrokeOrder,
); err != nil {
return nil, status.Errorf(codes.Internal, "insert stroke: %v", err)
}
}
}
if err := tx.Commit(); err != nil {
return nil, status.Errorf(codes.Internal, "commit: %v", err)
}
return &pb.SyncNotebookResponse{
ServerNotebookId: notebookID,
SyncedAt: timestamppb.Now(),
}, nil
}
func (s *SyncService) DeleteNotebook(ctx context.Context, req *pb.DeleteNotebookRequest) (*pb.DeleteNotebookResponse, error) {
userID, ok := UserIDFromContext(ctx)
if !ok {
return nil, status.Error(codes.Internal, "missing user context")
}
_, err := s.DB.ExecContext(ctx,
"DELETE FROM notebooks WHERE user_id = ? AND remote_id = ?",
userID, req.NotebookId,
)
if err != nil {
return nil, status.Errorf(codes.Internal, "delete: %v", err)
}
return &pb.DeleteNotebookResponse{}, nil
}
func (s *SyncService) ListNotebooks(ctx context.Context, req *pb.ListNotebooksRequest) (*pb.ListNotebooksResponse, error) {
userID, ok := UserIDFromContext(ctx)
if !ok {
return nil, status.Error(codes.Internal, "missing user context")
}
rows, err := s.DB.QueryContext(ctx,
`SELECT n.id, n.remote_id, n.title, n.page_size, n.synced_at,
(SELECT COUNT(*) FROM pages WHERE notebook_id = n.id) as page_count
FROM notebooks n WHERE n.user_id = ? ORDER BY n.synced_at DESC`,
userID,
)
if err != nil {
return nil, status.Errorf(codes.Internal, "query: %v", err)
}
defer func() { _ = rows.Close() }()
var notebooks []*pb.NotebookSummary
for rows.Next() {
var nb pb.NotebookSummary
var syncedAt int64
if err := rows.Scan(&nb.ServerId, &nb.RemoteId, &nb.Title, &nb.PageSize, &syncedAt, &nb.PageCount); err != nil {
return nil, status.Errorf(codes.Internal, "scan: %v", err)
}
nb.SyncedAt = timestamppb.New(time.UnixMilli(syncedAt))
notebooks = append(notebooks, &nb)
}
return &pb.ListNotebooksResponse{Notebooks: notebooks}, nil
}

105
internal/share/share.go Normal file
View File

@@ -0,0 +1,105 @@
package share
import (
"crypto/rand"
"database/sql"
"encoding/base64"
"fmt"
"time"
)
const tokenBytes = 32
type LinkInfo struct {
Token string
URL string
CreatedAt time.Time
ExpiresAt *time.Time
}
// CreateLink generates a shareable link for a notebook.
func CreateLink(database *sql.DB, notebookID int64, expiry time.Duration, baseURL string) (string, *time.Time, error) {
raw := make([]byte, tokenBytes)
if _, err := rand.Read(raw); err != nil {
return "", nil, fmt.Errorf("generate token: %w", err)
}
token := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(raw)
now := time.Now().UnixMilli()
var expiresAt *int64
var expiresTime *time.Time
if expiry > 0 {
ea := time.Now().Add(expiry).UnixMilli()
expiresAt = &ea
t := time.UnixMilli(ea)
expiresTime = &t
}
_, err := database.Exec(
"INSERT INTO share_links (notebook_id, token, expires_at, created_at) VALUES (?, ?, ?, ?)",
notebookID, token, expiresAt, now,
)
if err != nil {
return "", nil, fmt.Errorf("insert share link: %w", err)
}
return token, expiresTime, nil
}
// ValidateLink checks if a token is valid and returns the notebook ID.
func ValidateLink(database *sql.DB, token string) (int64, error) {
var notebookID int64
var expiresAt *int64
err := database.QueryRow(
"SELECT notebook_id, expires_at FROM share_links WHERE token = ?", token,
).Scan(&notebookID, &expiresAt)
if err != nil {
return 0, fmt.Errorf("link not found")
}
if expiresAt != nil && time.Now().UnixMilli() > *expiresAt {
return 0, fmt.Errorf("link expired")
}
return notebookID, nil
}
// RevokeLink deletes a share link.
func RevokeLink(database *sql.DB, token string) error {
_, err := database.Exec("DELETE FROM share_links WHERE token = ?", token)
return err
}
// ListLinks returns all active share links for a notebook.
func ListLinks(database *sql.DB, notebookID int64, baseURL string) ([]LinkInfo, error) {
rows, err := database.Query(
"SELECT token, created_at, expires_at FROM share_links WHERE notebook_id = ? ORDER BY created_at DESC",
notebookID,
)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var links []LinkInfo
for rows.Next() {
var token string
var createdAt int64
var expiresAt *int64
if err := rows.Scan(&token, &createdAt, &expiresAt); err != nil {
return nil, err
}
li := LinkInfo{
Token: token,
URL: baseURL + "/s/" + token,
CreatedAt: time.UnixMilli(createdAt),
}
if expiresAt != nil {
t := time.UnixMilli(*expiresAt)
li.ExpiresAt = &t
}
links = append(links, li)
}
return links, nil
}

View File

@@ -0,0 +1,94 @@
syntax = "proto3";
package engpad.v1;
option go_package = "git.wntrmute.dev/kyle/eng-pad-server/gen/engpad/v1;engpadv1";
import "google/protobuf/timestamp.proto";
service EngPadSync {
rpc SyncNotebook(SyncNotebookRequest) returns (SyncNotebookResponse);
rpc DeleteNotebook(DeleteNotebookRequest) returns (DeleteNotebookResponse);
rpc ListNotebooks(ListNotebooksRequest) returns (ListNotebooksResponse);
rpc CreateShareLink(CreateShareLinkRequest) returns (CreateShareLinkResponse);
rpc RevokeShareLink(RevokeShareLinkRequest) returns (RevokeShareLinkResponse);
rpc ListShareLinks(ListShareLinksRequest) returns (ListShareLinksResponse);
}
message SyncNotebookRequest {
int64 notebook_id = 1;
string title = 2;
string page_size = 3;
repeated PageData pages = 4;
}
message PageData {
int64 page_id = 1;
int32 page_number = 2;
repeated StrokeData strokes = 3;
}
message StrokeData {
float pen_size = 1;
int32 color = 2;
string style = 3;
bytes point_data = 4;
int32 stroke_order = 5;
}
message SyncNotebookResponse {
int64 server_notebook_id = 1;
google.protobuf.Timestamp synced_at = 2;
}
message DeleteNotebookRequest {
int64 notebook_id = 1;
}
message DeleteNotebookResponse {}
message ListNotebooksRequest {}
message ListNotebooksResponse {
repeated NotebookSummary notebooks = 1;
}
message NotebookSummary {
int64 server_id = 1;
int64 remote_id = 2;
string title = 3;
string page_size = 4;
int32 page_count = 5;
google.protobuf.Timestamp synced_at = 6;
}
message CreateShareLinkRequest {
int64 notebook_id = 1;
int64 expires_in_seconds = 2;
}
message CreateShareLinkResponse {
string token = 1;
string url = 2;
google.protobuf.Timestamp expires_at = 3;
}
message RevokeShareLinkRequest {
string token = 1;
}
message RevokeShareLinkResponse {}
message ListShareLinksRequest {
int64 notebook_id = 1;
}
message ListShareLinksResponse {
repeated ShareLinkInfo links = 1;
}
message ShareLinkInfo {
string token = 1;
string url = 2;
google.protobuf.Timestamp created_at = 3;
google.protobuf.Timestamp expires_at = 4;
}