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:
1031
gen/engpad/v1/sync.pb.go
Normal file
1031
gen/engpad/v1/sync.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
311
gen/engpad/v1/sync_grpc.pb.go
Normal file
311
gen/engpad/v1/sync_grpc.pb.go
Normal 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
5
go.mod
@@ -7,6 +7,8 @@ require (
|
|||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
golang.org/x/crypto v0.49.0
|
golang.org/x/crypto v0.49.0
|
||||||
golang.org/x/term v0.41.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
|
modernc.org/sqlite v1.47.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,7 +20,10 @@ require (
|
|||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/spf13/pflag v1.0.9 // 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/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/libc v1.70.0 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
|||||||
38
go.sum
38
go.sum
@@ -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/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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
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 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/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 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
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=
|
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 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
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 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
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/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
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.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 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
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 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
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/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 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
|||||||
46
internal/grpcserver/interceptors.go
Normal file
46
internal/grpcserver/interceptors.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
48
internal/grpcserver/server.go
Normal file
48
internal/grpcserver/server.go
Normal 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)
|
||||||
|
}
|
||||||
94
internal/grpcserver/share.go
Normal file
94
internal/grpcserver/share.go
Normal 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(¬ebookID)
|
||||||
|
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(¬ebookID)
|
||||||
|
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
142
internal/grpcserver/sync.go
Normal 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(¬ebookID)
|
||||||
|
|
||||||
|
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
105
internal/share/share.go
Normal 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(¬ebookID, &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
|
||||||
|
}
|
||||||
94
proto/engpad/v1/sync.proto
Normal file
94
proto/engpad/v1/sync.proto
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user