Compare commits

6 Commits

Author SHA1 Message Date
f1b67b9909 Add GetNotebook RPC for pulling complete notebook data
New RPC returns notebook metadata, all pages, and all strokes for
a given server-side notebook ID. Enables desktop and other clients
to download notebooks from the server (pull sync).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 15:06:20 -07:00
651eabe995 Add notebook delete, fix button styling, rename Share button
- Add delete notebook handler with ownership check and CASCADE delete
- Rename "Create Share Link" to "Share"
- Fix action button heights: use inline-flex + align-items for
  consistent sizing across <a> and <button> elements

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 09:43:57 -07:00
aeb12d9f50 Add rendering routes and share UI to web server
The web UI was linking to /v1/ REST API paths that aren't served
through nginx. Added SVG/JPG/PDF rendering and share link endpoints
directly to the web server so everything works through port 443.

- Add render.go with SVG, JPG, PDF handlers for auth and share paths
- Register render routes and share management routes in web server
- Update template links from /v1/... to /notebooks/... paths
- Add share link creation, display, and revocation to notebook view

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 09:22:07 -07:00
ab2884a8e9 Fix gRPC auth metadata keys, allow TLS 1.2 for Android clients
- Read x-engpad-username/x-engpad-password from gRPC metadata
  (matching what the Android client sends)
- Allow TLS 1.2 on gRPC port — Android's BoringSSL/OkHttp transport
  does not negotiate TLS 1.3 without Conscrypt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 09:08:24 -07:00
691301dade Update docs for Docker-on-deimos deployment, add grpc_plain_addr option
- ARCHITECTURE.md: document nginx + direct gRPC topology, add
  grpc_plain_addr config, update cert filenames to Let's Encrypt
  convention, add passwd to CLI table
- RUNBOOK.md: replace systemctl/journalctl with docker commands,
  fix cert path references, improve sync troubleshooting steps
- Example config: update cert paths, document grpc_plain_addr option
- grpcserver: add optional plaintext gRPC listener for reverse proxy
- config: add GRPCPlainAddr field

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 08:58:01 -07:00
2185bbe563 Add passwd command, fix template rendering, update deployment docs
- Add `passwd` CLI command to reset user passwords
- Fix web UI templates: parse each page template with layout so blocks
  render correctly (was outputting empty pages)
- Add login error logging for debugging auth failures
- Update README with deploy workflow and container management commands
- Update RUNBOOK for Docker-on-deimos deployment (replaces systemd refs)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 08:27:31 -07:00
19 changed files with 956 additions and 210 deletions

View File

@@ -226,13 +226,14 @@ Built with Go `html/template` + htmx. Embedded via `//go:embed`.
```toml
[server]
listen_addr = ":8443"
grpc_addr = ":9443"
tls_cert = "/srv/eng-pad-server/certs/cert.pem"
tls_key = "/srv/eng-pad-server/certs/key.pem"
listen_addr = ":8443" # REST API (HTTPS)
grpc_addr = ":9443" # gRPC (TLS, exposed directly)
grpc_plain_addr = "" # Optional plaintext gRPC for reverse proxy
tls_cert = "/srv/eng-pad-server/certs/fullchain.pem"
tls_key = "/srv/eng-pad-server/certs/privkey.pem"
[web]
listen_addr = ":8080"
listen_addr = ":8080" # Web UI (plain HTTP behind nginx)
base_url = "https://pad.metacircular.net"
[database]
@@ -255,13 +256,32 @@ level = "info"
## 9. Deployment
### Production (deimos.wntrmute.net)
Docker container behind nginx on deimos:
- **Web UI**: `https://pad.metacircular.net` — nginx (port 443) → container:8080
- **gRPC sync**: `pad.metacircular.net:9443` — direct TLS, exposed via ufw
- **REST API**: container:8443 — not exposed externally
- **TLS**: Let's Encrypt cert for `pad.metacircular.net`, shared by nginx
and the container (copied to `/srv/eng-pad-server/certs/`)
```
Internet
├── :443 → nginx (TLS termination) → container:8080 (Web UI, plain HTTP)
└── :9443 → container:9443 (gRPC, direct TLS)
```
### Container
Multi-stage Docker build:
1. Builder: `golang:1.25-alpine`, `CGO_ENABLED=0`, stripped binary
2. Runtime: `alpine:latest`, non-root user
2. Runtime: `alpine:3.21`, non-root user (`engpad`, UID 1000)
### systemd
### systemd (alternative)
systemd units are provided for non-Docker deployments:
| Unit | Purpose |
|------|---------|
@@ -279,8 +299,8 @@ ReadWritePaths=/srv/eng-pad-server.
├── eng-pad-server.toml
├── eng-pad-server.db
├── certs/
│ ├── cert.pem
│ └── key.pem
│ ├── fullchain.pem # Let's Encrypt cert chain
│ └── privkey.pem # Let's Encrypt private key
└── backups/
```
@@ -301,6 +321,7 @@ ReadWritePaths=/srv/eng-pad-server.
|---------|---------|
| server | Start the service |
| init | Create database, first user |
| passwd | Reset a user's password |
| snapshot | Database backup (VACUUM INTO) |
| status | Health check |

View File

@@ -49,7 +49,8 @@ eng-pad-server/
│ └── eng-pad-server/ CLI entry point (cobra)
│ ├── main.go
│ ├── server.go server subcommand
── init.go init subcommand
── init.go init subcommand
│ └── passwd.go password reset subcommand
├── internal/
│ ├── auth/
│ │ ├── argon2.go Password hashing

View File

@@ -27,10 +27,10 @@ cp eng-pad-server.toml.example /srv/eng-pad-server/eng-pad-server.toml
# Edit configuration (TLS certs, database path, etc.)
# Initialize (creates database, prompts for admin user)
./eng-pad-server init
./eng-pad-server init -c /srv/eng-pad-server/eng-pad-server.toml
# Run
./eng-pad-server server
./eng-pad-server server -c /srv/eng-pad-server/eng-pad-server.toml
```
## Build
@@ -43,6 +43,54 @@ make proto # regenerate gRPC code from .proto files
make proto-lint # buf lint + breaking change detection
```
## User Management
```bash
# Create initial user (interactive — prompts for username and password)
eng-pad-server init -c /srv/eng-pad-server/eng-pad-server.toml
# Reset a user's password
eng-pad-server passwd <username> -c /srv/eng-pad-server/eng-pad-server.toml
```
## Deployment (deimos.wntrmute.net)
The production instance runs as a Docker container on deimos behind nginx.
- **Web UI**: `https://pad.metacircular.net` (nginx → container:8080)
- **REST API**: `https://pad.metacircular.net:8443` (direct TLS)
- **gRPC sync**: `pad.metacircular.net:9443` (direct TLS, for Android app)
- **Data**: `/srv/eng-pad-server/` on deimos
- **TLS**: Let's Encrypt cert, shared by nginx and the container
### Deploy workflow
```bash
# From local machine:
rsync -az --exclude='.git' --exclude='srv/' . deimos.wntrmute.net:/tmp/eng-pad-server-build/
ssh deimos.wntrmute.net "cd /tmp/eng-pad-server-build && \
docker build -t eng-pad-server . && \
docker stop eng-pad-server && docker rm eng-pad-server && \
docker run -d --name eng-pad-server --restart unless-stopped \
-p 127.0.0.1:8090:8080 -p 8443:8443 -p 9443:9443 \
-v /srv/eng-pad-server:/srv/eng-pad-server eng-pad-server"
```
### Container management
```bash
# View logs
ssh deimos.wntrmute.net "docker logs eng-pad-server"
# Create/reset user
ssh -t deimos.wntrmute.net "docker exec -it eng-pad-server \
eng-pad-server passwd kyle -c /srv/eng-pad-server/eng-pad-server.toml"
# Renew TLS certs (after certbot renews)
ssh deimos.wntrmute.net "sudo cp /etc/letsencrypt/live/pad.metacircular.net/{fullchain,privkey}.pem \
/srv/eng-pad-server/certs/ && docker restart eng-pad-server"
```
## Documentation
- [ARCHITECTURE.md](ARCHITECTURE.md) — full system specification

View File

@@ -6,31 +6,29 @@ eng-pad-server receives engineering notebook data from the Engineering
Pad Android app via gRPC, stores it in SQLite, and serves read-only
views through a web UI. Single authenticated user.
**Ports**: 8443 (REST/HTTPS), 9443 (gRPC/TLS), 8080 (Web UI)
**Host**: deimos.wntrmute.net
**URL**: https://pad.metacircular.net
**Ports**: 443 (nginx → 8080 web UI), 8443 (REST/TLS), 9443 (gRPC/TLS)
**Data**: `/srv/eng-pad-server/`
**Config**: `/srv/eng-pad-server/eng-pad-server.toml`
**Binary**: `/usr/local/bin/eng-pad-server`
**TLS**: Let's Encrypt (`/etc/letsencrypt/live/pad.metacircular.net/`), copied to `/srv/eng-pad-server/certs/`
**Container**: `eng-pad-server` (Docker, `--restart unless-stopped`)
## 2. Health Checks
1. Check service is running:
1. Check container is running:
```
systemctl status eng-pad-server
docker ps | grep eng-pad-server
```
2. Check database health:
2. Check web UI responds:
```
eng-pad-server status -c /srv/eng-pad-server/eng-pad-server.toml
curl -s https://pad.metacircular.net/login | head -1
```
3. Check web UI responds:
3. Check container logs:
```
curl -k https://localhost:8443/login
```
4. Check gRPC responds:
```
grpcurl -insecure localhost:9443 list
docker logs eng-pad-server --tail 20
```
## 3. Common Operations
@@ -38,89 +36,74 @@ views through a web UI. Single authenticated user.
### Start / Stop / Restart
```
systemctl start eng-pad-server
systemctl stop eng-pad-server
systemctl restart eng-pad-server
docker start eng-pad-server
docker stop eng-pad-server
docker restart eng-pad-server
```
### View Logs
```
journalctl -u eng-pad-server -f
docker logs eng-pad-server -f
```
### Deploy New Version
```bash
# From local machine:
rsync -az --exclude='.git' --exclude='srv/' . deimos.wntrmute.net:/tmp/eng-pad-server-build/
ssh deimos.wntrmute.net "cd /tmp/eng-pad-server-build && \
docker build -t eng-pad-server . && \
docker stop eng-pad-server && docker rm eng-pad-server && \
docker run -d --name eng-pad-server --restart unless-stopped \
-p 127.0.0.1:8090:8080 -p 8443:8443 -p 9443:9443 \
-v /srv/eng-pad-server:/srv/eng-pad-server eng-pad-server"
```
### Create User
```
docker exec -it eng-pad-server \
eng-pad-server init -c /srv/eng-pad-server/eng-pad-server.toml
```
### Reset User Password
```
docker exec -it eng-pad-server \
eng-pad-server passwd <username> -c /srv/eng-pad-server/eng-pad-server.toml
```
### Manual Backup
```
docker exec eng-pad-server \
eng-pad-server snapshot -c /srv/eng-pad-server/eng-pad-server.toml
```
Backup saved to `/srv/eng-pad-server/backups/`.
### Check Backup Timer
### Renew TLS Certificates
After certbot renews the Let's Encrypt cert:
```
systemctl list-timers eng-pad-server-backup.timer
```
### Initialize (First Time)
1. Install the binary and config:
```
sudo deploy/scripts/install.sh
```
2. Edit the config file:
```
sudo -u engpad vi /srv/eng-pad-server/eng-pad-server.toml
```
3. Generate TLS certificates (or copy existing ones):
```
# Self-signed for development:
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
-keyout /srv/eng-pad-server/certs/key.pem \
-out /srv/eng-pad-server/certs/cert.pem \
-days 3650 -nodes -subj '/CN=pad.metacircular.net'
chown engpad:engpad /srv/eng-pad-server/certs/*.pem
chmod 600 /srv/eng-pad-server/certs/key.pem
```
4. Create the admin user:
```
eng-pad-server init -c /srv/eng-pad-server/eng-pad-server.toml
```
5. Start the service:
```
systemctl enable --now eng-pad-server
systemctl enable --now eng-pad-server-backup.timer
sudo cp /etc/letsencrypt/live/pad.metacircular.net/{fullchain,privkey}.pem \
/srv/eng-pad-server/certs/
docker restart eng-pad-server
```
### Register a FIDO2/U2F Security Key
1. Log in to the web UI with password.
1. Log in to the web UI at https://pad.metacircular.net with password.
2. Navigate to `/keys`.
3. Enter a name for the key (e.g., "YubiKey 5").
4. Click "Register" and touch the key when prompted.
### Docker Deployment
```
cd deploy/docker
docker compose up -d
```
First-time setup inside the container:
```
docker compose exec eng-pad-server eng-pad-server init -c /srv/eng-pad-server/eng-pad-server.toml
```
## 4. Alerting
No automated alerting is configured. Monitor via:
- `systemctl status eng-pad-server` — process health
- `journalctl -u eng-pad-server --since "1 hour ago" | grep ERROR` — errors
- `docker ps | grep eng-pad-server` — container health
- `docker logs eng-pad-server --since 1h 2>&1 | grep ERROR` — errors
- Backup age: `ls -lt /srv/eng-pad-server/backups/ | head`
## 5. Incident Procedures
@@ -129,19 +112,19 @@ No automated alerting is configured. Monitor via:
1. Check logs:
```
journalctl -u eng-pad-server -n 50 --no-pager
docker logs eng-pad-server --tail 50
```
2. Common causes:
- Config file missing or invalid → fix config
- TLS cert/key missing → regenerate or copy
- Port already in use → `ss -tlnp | grep 8443`
- Config file missing or invalid → fix `/srv/eng-pad-server/eng-pad-server.toml`
- TLS cert/key missing → re-copy from Let's Encrypt (see Renew TLS above)
- Port already in use → `ss -tlnp | grep -E '8443|9443|8090'`
- Database locked → check for zombie processes: `fuser /srv/eng-pad-server/eng-pad-server.db`
### Database Corruption
1. Stop the service:
1. Stop the container:
```
systemctl stop eng-pad-server
docker stop eng-pad-server
```
2. Check integrity:
```
@@ -150,21 +133,20 @@ No automated alerting is configured. Monitor via:
3. If corrupted, restore from backup:
```
cp /srv/eng-pad-server/backups/eng-pad-server-LATEST.db /srv/eng-pad-server/eng-pad-server.db
chown engpad:engpad /srv/eng-pad-server/eng-pad-server.db
```
4. Restart:
```
systemctl start eng-pad-server
docker start eng-pad-server
```
### Certificate Expiry
1. Check expiry:
```
openssl x509 -in /srv/eng-pad-server/certs/cert.pem -noout -dates
openssl x509 -in /srv/eng-pad-server/certs/fullchain.pem -noout -dates
```
2. Regenerate or renew the certificate.
3. Restart the service (picks up new certs on start).
2. Renew via certbot (see "Renew TLS Certificates" above).
3. Restart the container (picks up new certs on start).
### Disk Full
@@ -184,11 +166,12 @@ No automated alerting is configured. Monitor via:
### Sync Fails from Android App
1. Verify server is reachable from the device's network.
2. Check gRPC port is open: `ss -tlnp | grep 9443`
3. Check TLS cert is valid and trusted by the device.
4. Check credentials: verify the user exists via `eng-pad-server status`.
5. Check server logs for auth failures: `journalctl -u eng-pad-server | grep UNAUTHENTICATED`
1. Verify the app has the correct server URL (`pad.metacircular.net:9443`).
2. Use "Test Connection" in the app's sync settings for a specific error.
3. Check gRPC port is open: `ss -tlnp | grep 9443`
4. Check firewall: `sudo ufw status | grep 9443` (must be ALLOW).
5. Check TLS cert is valid: `openssl x509 -in /srv/eng-pad-server/certs/fullchain.pem -noout -dates`
6. Check server logs for auth failures: `docker logs eng-pad-server 2>&1 | grep -i error`
## 6. Escalation

View File

@@ -0,0 +1,77 @@
package main
import (
"fmt"
"os"
"strings"
"git.wntrmute.dev/kyle/eng-pad-server/internal/auth"
"git.wntrmute.dev/kyle/eng-pad-server/internal/config"
"git.wntrmute.dev/kyle/eng-pad-server/internal/db"
"github.com/spf13/cobra"
"golang.org/x/term"
)
var passwdCmd = &cobra.Command{
Use: "passwd <username>",
Short: "Set password for a user",
Args: cobra.ExactArgs(1),
RunE: runPasswd,
}
func init() {
rootCmd.AddCommand(passwdCmd)
}
func runPasswd(cmd *cobra.Command, args []string) error {
username := args[0]
cfg, err := config.Load(cfgFile)
if err != nil {
return err
}
database, err := db.Open(cfg.Database.Path)
if err != nil {
return err
}
defer func() { _ = database.Close() }()
// Verify user exists.
var userID int64
err = database.QueryRow("SELECT id FROM users WHERE username = ?", username).Scan(&userID)
if err != nil {
return fmt.Errorf("user %q not found", username)
}
fmt.Print("New password: ")
passBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
return fmt.Errorf("read password: %w", err)
}
password := strings.TrimSpace(string(passBytes))
if password == "" {
return fmt.Errorf("password cannot be empty")
}
params := auth.Argon2Params{
Memory: cfg.Auth.Argon2Memory,
Time: cfg.Auth.Argon2Time,
Threads: cfg.Auth.Argon2Threads,
}
hash, err := auth.HashPassword(password, params)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
_, err = database.Exec("UPDATE users SET password_hash = ?, updated_at = unixepoch() WHERE id = ?", hash, userID)
if err != nil {
return fmt.Errorf("update password: %w", err)
}
fmt.Printf("Password updated for %q.\n", username)
return nil
}

View File

@@ -51,6 +51,7 @@ func runServer(cmd *cobra.Command, args []string) error {
// Start gRPC server
grpcSrv, err := grpcserver.Start(grpcserver.Config{
Addr: cfg.Server.GRPCAddr,
PlainAddr: cfg.Server.GRPCPlainAddr,
TLSCert: cfg.Server.TLSCert,
TLSKey: cfg.Server.TLSKey,
DB: database,

View File

@@ -1,8 +1,9 @@
[server]
listen_addr = ":8443"
grpc_addr = ":9443"
tls_cert = "/srv/eng-pad-server/certs/cert.pem"
tls_key = "/srv/eng-pad-server/certs/key.pem"
# grpc_plain_addr = "127.0.0.1:9444" # Optional: plaintext gRPC for reverse proxy
tls_cert = "/srv/eng-pad-server/certs/fullchain.pem"
tls_key = "/srv/eng-pad-server/certs/privkey.pem"
[web]
listen_addr = ":8080"

View File

@@ -278,6 +278,134 @@ func (x *SyncNotebookResponse) GetSyncedAt() *timestamppb.Timestamp {
return nil
}
type GetNotebookRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
NotebookId int64 `protobuf:"varint,1,opt,name=notebook_id,json=notebookId,proto3" json:"notebook_id,omitempty"` // Server-side notebook ID
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetNotebookRequest) Reset() {
*x = GetNotebookRequest{}
mi := &file_proto_engpad_v1_sync_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetNotebookRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetNotebookRequest) ProtoMessage() {}
func (x *GetNotebookRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_engpad_v1_sync_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 GetNotebookRequest.ProtoReflect.Descriptor instead.
func (*GetNotebookRequest) Descriptor() ([]byte, []int) {
return file_proto_engpad_v1_sync_proto_rawDescGZIP(), []int{4}
}
func (x *GetNotebookRequest) GetNotebookId() int64 {
if x != nil {
return x.NotebookId
}
return 0
}
type GetNotebookResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
ServerNotebookId int64 `protobuf:"varint,1,opt,name=server_notebook_id,json=serverNotebookId,proto3" json:"server_notebook_id,omitempty"`
RemoteId int64 `protobuf:"varint,2,opt,name=remote_id,json=remoteId,proto3" json:"remote_id,omitempty"`
Title string `protobuf:"bytes,3,opt,name=title,proto3" json:"title,omitempty"`
PageSize string `protobuf:"bytes,4,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
Pages []*PageData `protobuf:"bytes,5,rep,name=pages,proto3" json:"pages,omitempty"`
SyncedAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=synced_at,json=syncedAt,proto3" json:"synced_at,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetNotebookResponse) Reset() {
*x = GetNotebookResponse{}
mi := &file_proto_engpad_v1_sync_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetNotebookResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetNotebookResponse) ProtoMessage() {}
func (x *GetNotebookResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_engpad_v1_sync_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 GetNotebookResponse.ProtoReflect.Descriptor instead.
func (*GetNotebookResponse) Descriptor() ([]byte, []int) {
return file_proto_engpad_v1_sync_proto_rawDescGZIP(), []int{5}
}
func (x *GetNotebookResponse) GetServerNotebookId() int64 {
if x != nil {
return x.ServerNotebookId
}
return 0
}
func (x *GetNotebookResponse) GetRemoteId() int64 {
if x != nil {
return x.RemoteId
}
return 0
}
func (x *GetNotebookResponse) GetTitle() string {
if x != nil {
return x.Title
}
return ""
}
func (x *GetNotebookResponse) GetPageSize() string {
if x != nil {
return x.PageSize
}
return ""
}
func (x *GetNotebookResponse) GetPages() []*PageData {
if x != nil {
return x.Pages
}
return nil
}
func (x *GetNotebookResponse) GetSyncedAt() *timestamppb.Timestamp {
if x != nil {
return x.SyncedAt
}
return nil
}
type DeleteNotebookRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
NotebookId int64 `protobuf:"varint,1,opt,name=notebook_id,json=notebookId,proto3" json:"notebook_id,omitempty"`
@@ -287,7 +415,7 @@ type DeleteNotebookRequest struct {
func (x *DeleteNotebookRequest) Reset() {
*x = DeleteNotebookRequest{}
mi := &file_proto_engpad_v1_sync_proto_msgTypes[4]
mi := &file_proto_engpad_v1_sync_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -299,7 +427,7 @@ func (x *DeleteNotebookRequest) String() string {
func (*DeleteNotebookRequest) ProtoMessage() {}
func (x *DeleteNotebookRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_engpad_v1_sync_proto_msgTypes[4]
mi := &file_proto_engpad_v1_sync_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -312,7 +440,7 @@ func (x *DeleteNotebookRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use DeleteNotebookRequest.ProtoReflect.Descriptor instead.
func (*DeleteNotebookRequest) Descriptor() ([]byte, []int) {
return file_proto_engpad_v1_sync_proto_rawDescGZIP(), []int{4}
return file_proto_engpad_v1_sync_proto_rawDescGZIP(), []int{6}
}
func (x *DeleteNotebookRequest) GetNotebookId() int64 {
@@ -330,7 +458,7 @@ type DeleteNotebookResponse struct {
func (x *DeleteNotebookResponse) Reset() {
*x = DeleteNotebookResponse{}
mi := &file_proto_engpad_v1_sync_proto_msgTypes[5]
mi := &file_proto_engpad_v1_sync_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -342,7 +470,7 @@ func (x *DeleteNotebookResponse) String() string {
func (*DeleteNotebookResponse) ProtoMessage() {}
func (x *DeleteNotebookResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_engpad_v1_sync_proto_msgTypes[5]
mi := &file_proto_engpad_v1_sync_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -355,7 +483,7 @@ func (x *DeleteNotebookResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use DeleteNotebookResponse.ProtoReflect.Descriptor instead.
func (*DeleteNotebookResponse) Descriptor() ([]byte, []int) {
return file_proto_engpad_v1_sync_proto_rawDescGZIP(), []int{5}
return file_proto_engpad_v1_sync_proto_rawDescGZIP(), []int{7}
}
type ListNotebooksRequest struct {
@@ -366,7 +494,7 @@ type ListNotebooksRequest struct {
func (x *ListNotebooksRequest) Reset() {
*x = ListNotebooksRequest{}
mi := &file_proto_engpad_v1_sync_proto_msgTypes[6]
mi := &file_proto_engpad_v1_sync_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -378,7 +506,7 @@ func (x *ListNotebooksRequest) String() string {
func (*ListNotebooksRequest) ProtoMessage() {}
func (x *ListNotebooksRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_engpad_v1_sync_proto_msgTypes[6]
mi := &file_proto_engpad_v1_sync_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -391,7 +519,7 @@ func (x *ListNotebooksRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListNotebooksRequest.ProtoReflect.Descriptor instead.
func (*ListNotebooksRequest) Descriptor() ([]byte, []int) {
return file_proto_engpad_v1_sync_proto_rawDescGZIP(), []int{6}
return file_proto_engpad_v1_sync_proto_rawDescGZIP(), []int{8}
}
type ListNotebooksResponse struct {
@@ -403,7 +531,7 @@ type ListNotebooksResponse struct {
func (x *ListNotebooksResponse) Reset() {
*x = ListNotebooksResponse{}
mi := &file_proto_engpad_v1_sync_proto_msgTypes[7]
mi := &file_proto_engpad_v1_sync_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -415,7 +543,7 @@ func (x *ListNotebooksResponse) String() string {
func (*ListNotebooksResponse) ProtoMessage() {}
func (x *ListNotebooksResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_engpad_v1_sync_proto_msgTypes[7]
mi := &file_proto_engpad_v1_sync_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -428,7 +556,7 @@ func (x *ListNotebooksResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListNotebooksResponse.ProtoReflect.Descriptor instead.
func (*ListNotebooksResponse) Descriptor() ([]byte, []int) {
return file_proto_engpad_v1_sync_proto_rawDescGZIP(), []int{7}
return file_proto_engpad_v1_sync_proto_rawDescGZIP(), []int{9}
}
func (x *ListNotebooksResponse) GetNotebooks() []*NotebookSummary {
@@ -452,7 +580,7 @@ type NotebookSummary struct {
func (x *NotebookSummary) Reset() {
*x = NotebookSummary{}
mi := &file_proto_engpad_v1_sync_proto_msgTypes[8]
mi := &file_proto_engpad_v1_sync_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -464,7 +592,7 @@ func (x *NotebookSummary) String() string {
func (*NotebookSummary) ProtoMessage() {}
func (x *NotebookSummary) ProtoReflect() protoreflect.Message {
mi := &file_proto_engpad_v1_sync_proto_msgTypes[8]
mi := &file_proto_engpad_v1_sync_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -477,7 +605,7 @@ func (x *NotebookSummary) ProtoReflect() protoreflect.Message {
// Deprecated: Use NotebookSummary.ProtoReflect.Descriptor instead.
func (*NotebookSummary) Descriptor() ([]byte, []int) {
return file_proto_engpad_v1_sync_proto_rawDescGZIP(), []int{8}
return file_proto_engpad_v1_sync_proto_rawDescGZIP(), []int{10}
}
func (x *NotebookSummary) GetServerId() int64 {
@@ -532,7 +660,7 @@ type CreateShareLinkRequest struct {
func (x *CreateShareLinkRequest) Reset() {
*x = CreateShareLinkRequest{}
mi := &file_proto_engpad_v1_sync_proto_msgTypes[9]
mi := &file_proto_engpad_v1_sync_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -544,7 +672,7 @@ func (x *CreateShareLinkRequest) String() string {
func (*CreateShareLinkRequest) ProtoMessage() {}
func (x *CreateShareLinkRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_engpad_v1_sync_proto_msgTypes[9]
mi := &file_proto_engpad_v1_sync_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -557,7 +685,7 @@ func (x *CreateShareLinkRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use CreateShareLinkRequest.ProtoReflect.Descriptor instead.
func (*CreateShareLinkRequest) Descriptor() ([]byte, []int) {
return file_proto_engpad_v1_sync_proto_rawDescGZIP(), []int{9}
return file_proto_engpad_v1_sync_proto_rawDescGZIP(), []int{11}
}
func (x *CreateShareLinkRequest) GetNotebookId() int64 {
@@ -585,7 +713,7 @@ type CreateShareLinkResponse struct {
func (x *CreateShareLinkResponse) Reset() {
*x = CreateShareLinkResponse{}
mi := &file_proto_engpad_v1_sync_proto_msgTypes[10]
mi := &file_proto_engpad_v1_sync_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -597,7 +725,7 @@ func (x *CreateShareLinkResponse) String() string {
func (*CreateShareLinkResponse) ProtoMessage() {}
func (x *CreateShareLinkResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_engpad_v1_sync_proto_msgTypes[10]
mi := &file_proto_engpad_v1_sync_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -610,7 +738,7 @@ func (x *CreateShareLinkResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use CreateShareLinkResponse.ProtoReflect.Descriptor instead.
func (*CreateShareLinkResponse) Descriptor() ([]byte, []int) {
return file_proto_engpad_v1_sync_proto_rawDescGZIP(), []int{10}
return file_proto_engpad_v1_sync_proto_rawDescGZIP(), []int{12}
}
func (x *CreateShareLinkResponse) GetToken() string {
@@ -643,7 +771,7 @@ type RevokeShareLinkRequest struct {
func (x *RevokeShareLinkRequest) Reset() {
*x = RevokeShareLinkRequest{}
mi := &file_proto_engpad_v1_sync_proto_msgTypes[11]
mi := &file_proto_engpad_v1_sync_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -655,7 +783,7 @@ func (x *RevokeShareLinkRequest) String() string {
func (*RevokeShareLinkRequest) ProtoMessage() {}
func (x *RevokeShareLinkRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_engpad_v1_sync_proto_msgTypes[11]
mi := &file_proto_engpad_v1_sync_proto_msgTypes[13]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -668,7 +796,7 @@ func (x *RevokeShareLinkRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use RevokeShareLinkRequest.ProtoReflect.Descriptor instead.
func (*RevokeShareLinkRequest) Descriptor() ([]byte, []int) {
return file_proto_engpad_v1_sync_proto_rawDescGZIP(), []int{11}
return file_proto_engpad_v1_sync_proto_rawDescGZIP(), []int{13}
}
func (x *RevokeShareLinkRequest) GetToken() string {
@@ -686,7 +814,7 @@ type RevokeShareLinkResponse struct {
func (x *RevokeShareLinkResponse) Reset() {
*x = RevokeShareLinkResponse{}
mi := &file_proto_engpad_v1_sync_proto_msgTypes[12]
mi := &file_proto_engpad_v1_sync_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -698,7 +826,7 @@ func (x *RevokeShareLinkResponse) String() string {
func (*RevokeShareLinkResponse) ProtoMessage() {}
func (x *RevokeShareLinkResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_engpad_v1_sync_proto_msgTypes[12]
mi := &file_proto_engpad_v1_sync_proto_msgTypes[14]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -711,7 +839,7 @@ func (x *RevokeShareLinkResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use RevokeShareLinkResponse.ProtoReflect.Descriptor instead.
func (*RevokeShareLinkResponse) Descriptor() ([]byte, []int) {
return file_proto_engpad_v1_sync_proto_rawDescGZIP(), []int{12}
return file_proto_engpad_v1_sync_proto_rawDescGZIP(), []int{14}
}
type ListShareLinksRequest struct {
@@ -723,7 +851,7 @@ type ListShareLinksRequest struct {
func (x *ListShareLinksRequest) Reset() {
*x = ListShareLinksRequest{}
mi := &file_proto_engpad_v1_sync_proto_msgTypes[13]
mi := &file_proto_engpad_v1_sync_proto_msgTypes[15]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -735,7 +863,7 @@ func (x *ListShareLinksRequest) String() string {
func (*ListShareLinksRequest) ProtoMessage() {}
func (x *ListShareLinksRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_engpad_v1_sync_proto_msgTypes[13]
mi := &file_proto_engpad_v1_sync_proto_msgTypes[15]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -748,7 +876,7 @@ func (x *ListShareLinksRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListShareLinksRequest.ProtoReflect.Descriptor instead.
func (*ListShareLinksRequest) Descriptor() ([]byte, []int) {
return file_proto_engpad_v1_sync_proto_rawDescGZIP(), []int{13}
return file_proto_engpad_v1_sync_proto_rawDescGZIP(), []int{15}
}
func (x *ListShareLinksRequest) GetNotebookId() int64 {
@@ -767,7 +895,7 @@ type ListShareLinksResponse struct {
func (x *ListShareLinksResponse) Reset() {
*x = ListShareLinksResponse{}
mi := &file_proto_engpad_v1_sync_proto_msgTypes[14]
mi := &file_proto_engpad_v1_sync_proto_msgTypes[16]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -779,7 +907,7 @@ func (x *ListShareLinksResponse) String() string {
func (*ListShareLinksResponse) ProtoMessage() {}
func (x *ListShareLinksResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_engpad_v1_sync_proto_msgTypes[14]
mi := &file_proto_engpad_v1_sync_proto_msgTypes[16]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -792,7 +920,7 @@ func (x *ListShareLinksResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListShareLinksResponse.ProtoReflect.Descriptor instead.
func (*ListShareLinksResponse) Descriptor() ([]byte, []int) {
return file_proto_engpad_v1_sync_proto_rawDescGZIP(), []int{14}
return file_proto_engpad_v1_sync_proto_rawDescGZIP(), []int{16}
}
func (x *ListShareLinksResponse) GetLinks() []*ShareLinkInfo {
@@ -814,7 +942,7 @@ type ShareLinkInfo struct {
func (x *ShareLinkInfo) Reset() {
*x = ShareLinkInfo{}
mi := &file_proto_engpad_v1_sync_proto_msgTypes[15]
mi := &file_proto_engpad_v1_sync_proto_msgTypes[17]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -826,7 +954,7 @@ func (x *ShareLinkInfo) String() string {
func (*ShareLinkInfo) ProtoMessage() {}
func (x *ShareLinkInfo) ProtoReflect() protoreflect.Message {
mi := &file_proto_engpad_v1_sync_proto_msgTypes[15]
mi := &file_proto_engpad_v1_sync_proto_msgTypes[17]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -839,7 +967,7 @@ func (x *ShareLinkInfo) ProtoReflect() protoreflect.Message {
// Deprecated: Use ShareLinkInfo.ProtoReflect.Descriptor instead.
func (*ShareLinkInfo) Descriptor() ([]byte, []int) {
return file_proto_engpad_v1_sync_proto_rawDescGZIP(), []int{15}
return file_proto_engpad_v1_sync_proto_rawDescGZIP(), []int{17}
}
func (x *ShareLinkInfo) GetToken() string {
@@ -896,7 +1024,17 @@ const file_proto_engpad_v1_sync_proto_rawDesc = "" +
"\fstroke_order\x18\x05 \x01(\x05R\vstrokeOrder\"}\n" +
"\x14SyncNotebookResponse\x12,\n" +
"\x12server_notebook_id\x18\x01 \x01(\x03R\x10serverNotebookId\x127\n" +
"\tsynced_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\bsyncedAt\"8\n" +
"\tsynced_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\bsyncedAt\"5\n" +
"\x12GetNotebookRequest\x12\x1f\n" +
"\vnotebook_id\x18\x01 \x01(\x03R\n" +
"notebookId\"\xf7\x01\n" +
"\x13GetNotebookResponse\x12,\n" +
"\x12server_notebook_id\x18\x01 \x01(\x03R\x10serverNotebookId\x12\x1b\n" +
"\tremote_id\x18\x02 \x01(\x03R\bremoteId\x12\x14\n" +
"\x05title\x18\x03 \x01(\tR\x05title\x12\x1b\n" +
"\tpage_size\x18\x04 \x01(\tR\bpageSize\x12)\n" +
"\x05pages\x18\x05 \x03(\v2\x13.engpad.v1.PageDataR\x05pages\x127\n" +
"\tsynced_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\bsyncedAt\"8\n" +
"\x15DeleteNotebookRequest\x12\x1f\n" +
"\vnotebook_id\x18\x01 \x01(\x03R\n" +
"notebookId\"\x18\n" +
@@ -935,9 +1073,10 @@ const file_proto_engpad_v1_sync_proto_rawDesc = "" +
"\n" +
"created_at\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x129\n" +
"\n" +
"expires_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt2\x9a\x04\n" +
"expires_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt2\xe8\x04\n" +
"\x11EngPadSyncService\x12O\n" +
"\fSyncNotebook\x12\x1e.engpad.v1.SyncNotebookRequest\x1a\x1f.engpad.v1.SyncNotebookResponse\x12U\n" +
"\fSyncNotebook\x12\x1e.engpad.v1.SyncNotebookRequest\x1a\x1f.engpad.v1.SyncNotebookResponse\x12L\n" +
"\vGetNotebook\x12\x1d.engpad.v1.GetNotebookRequest\x1a\x1e.engpad.v1.GetNotebookResponse\x12U\n" +
"\x0eDeleteNotebook\x12 .engpad.v1.DeleteNotebookRequest\x1a!.engpad.v1.DeleteNotebookResponse\x12R\n" +
"\rListNotebooks\x12\x1f.engpad.v1.ListNotebooksRequest\x1a .engpad.v1.ListNotebooksResponse\x12X\n" +
"\x0fCreateShareLink\x12!.engpad.v1.CreateShareLinkRequest\x1a\".engpad.v1.CreateShareLinkResponse\x12X\n" +
@@ -957,53 +1096,59 @@ func file_proto_engpad_v1_sync_proto_rawDescGZIP() []byte {
return file_proto_engpad_v1_sync_proto_rawDescData
}
var file_proto_engpad_v1_sync_proto_msgTypes = make([]protoimpl.MessageInfo, 16)
var file_proto_engpad_v1_sync_proto_msgTypes = make([]protoimpl.MessageInfo, 18)
var file_proto_engpad_v1_sync_proto_goTypes = []any{
(*SyncNotebookRequest)(nil), // 0: engpad.v1.SyncNotebookRequest
(*PageData)(nil), // 1: engpad.v1.PageData
(*StrokeData)(nil), // 2: engpad.v1.StrokeData
(*SyncNotebookResponse)(nil), // 3: engpad.v1.SyncNotebookResponse
(*DeleteNotebookRequest)(nil), // 4: engpad.v1.DeleteNotebookRequest
(*DeleteNotebookResponse)(nil), // 5: engpad.v1.DeleteNotebookResponse
(*ListNotebooksRequest)(nil), // 6: engpad.v1.ListNotebooksRequest
(*ListNotebooksResponse)(nil), // 7: engpad.v1.ListNotebooksResponse
(*NotebookSummary)(nil), // 8: engpad.v1.NotebookSummary
(*CreateShareLinkRequest)(nil), // 9: engpad.v1.CreateShareLinkRequest
(*CreateShareLinkResponse)(nil), // 10: engpad.v1.CreateShareLinkResponse
(*RevokeShareLinkRequest)(nil), // 11: engpad.v1.RevokeShareLinkRequest
(*RevokeShareLinkResponse)(nil), // 12: engpad.v1.RevokeShareLinkResponse
(*ListShareLinksRequest)(nil), // 13: engpad.v1.ListShareLinksRequest
(*ListShareLinksResponse)(nil), // 14: engpad.v1.ListShareLinksResponse
(*ShareLinkInfo)(nil), // 15: engpad.v1.ShareLinkInfo
(*timestamppb.Timestamp)(nil), // 16: google.protobuf.Timestamp
(*GetNotebookRequest)(nil), // 4: engpad.v1.GetNotebookRequest
(*GetNotebookResponse)(nil), // 5: engpad.v1.GetNotebookResponse
(*DeleteNotebookRequest)(nil), // 6: engpad.v1.DeleteNotebookRequest
(*DeleteNotebookResponse)(nil), // 7: engpad.v1.DeleteNotebookResponse
(*ListNotebooksRequest)(nil), // 8: engpad.v1.ListNotebooksRequest
(*ListNotebooksResponse)(nil), // 9: engpad.v1.ListNotebooksResponse
(*NotebookSummary)(nil), // 10: engpad.v1.NotebookSummary
(*CreateShareLinkRequest)(nil), // 11: engpad.v1.CreateShareLinkRequest
(*CreateShareLinkResponse)(nil), // 12: engpad.v1.CreateShareLinkResponse
(*RevokeShareLinkRequest)(nil), // 13: engpad.v1.RevokeShareLinkRequest
(*RevokeShareLinkResponse)(nil), // 14: engpad.v1.RevokeShareLinkResponse
(*ListShareLinksRequest)(nil), // 15: engpad.v1.ListShareLinksRequest
(*ListShareLinksResponse)(nil), // 16: engpad.v1.ListShareLinksResponse
(*ShareLinkInfo)(nil), // 17: engpad.v1.ShareLinkInfo
(*timestamppb.Timestamp)(nil), // 18: google.protobuf.Timestamp
}
var file_proto_engpad_v1_sync_proto_depIdxs = []int32{
1, // 0: engpad.v1.SyncNotebookRequest.pages:type_name -> engpad.v1.PageData
2, // 1: engpad.v1.PageData.strokes:type_name -> engpad.v1.StrokeData
16, // 2: engpad.v1.SyncNotebookResponse.synced_at:type_name -> google.protobuf.Timestamp
8, // 3: engpad.v1.ListNotebooksResponse.notebooks:type_name -> engpad.v1.NotebookSummary
16, // 4: engpad.v1.NotebookSummary.synced_at:type_name -> google.protobuf.Timestamp
16, // 5: engpad.v1.CreateShareLinkResponse.expires_at:type_name -> google.protobuf.Timestamp
15, // 6: engpad.v1.ListShareLinksResponse.links:type_name -> engpad.v1.ShareLinkInfo
16, // 7: engpad.v1.ShareLinkInfo.created_at:type_name -> google.protobuf.Timestamp
16, // 8: engpad.v1.ShareLinkInfo.expires_at:type_name -> google.protobuf.Timestamp
0, // 9: engpad.v1.EngPadSyncService.SyncNotebook:input_type -> engpad.v1.SyncNotebookRequest
4, // 10: engpad.v1.EngPadSyncService.DeleteNotebook:input_type -> engpad.v1.DeleteNotebookRequest
6, // 11: engpad.v1.EngPadSyncService.ListNotebooks:input_type -> engpad.v1.ListNotebooksRequest
9, // 12: engpad.v1.EngPadSyncService.CreateShareLink:input_type -> engpad.v1.CreateShareLinkRequest
11, // 13: engpad.v1.EngPadSyncService.RevokeShareLink:input_type -> engpad.v1.RevokeShareLinkRequest
13, // 14: engpad.v1.EngPadSyncService.ListShareLinks:input_type -> engpad.v1.ListShareLinksRequest
3, // 15: engpad.v1.EngPadSyncService.SyncNotebook:output_type -> engpad.v1.SyncNotebookResponse
5, // 16: engpad.v1.EngPadSyncService.DeleteNotebook:output_type -> engpad.v1.DeleteNotebookResponse
7, // 17: engpad.v1.EngPadSyncService.ListNotebooks:output_type -> engpad.v1.ListNotebooksResponse
10, // 18: engpad.v1.EngPadSyncService.CreateShareLink:output_type -> engpad.v1.CreateShareLinkResponse
12, // 19: engpad.v1.EngPadSyncService.RevokeShareLink:output_type -> engpad.v1.RevokeShareLinkResponse
14, // 20: engpad.v1.EngPadSyncService.ListShareLinks:output_type -> engpad.v1.ListShareLinksResponse
15, // [15:21] is the sub-list for method output_type
9, // [9:15] is the sub-list for method input_type
9, // [9:9] is the sub-list for extension type_name
9, // [9:9] is the sub-list for extension extendee
0, // [0:9] is the sub-list for field type_name
18, // 2: engpad.v1.SyncNotebookResponse.synced_at:type_name -> google.protobuf.Timestamp
1, // 3: engpad.v1.GetNotebookResponse.pages:type_name -> engpad.v1.PageData
18, // 4: engpad.v1.GetNotebookResponse.synced_at:type_name -> google.protobuf.Timestamp
10, // 5: engpad.v1.ListNotebooksResponse.notebooks:type_name -> engpad.v1.NotebookSummary
18, // 6: engpad.v1.NotebookSummary.synced_at:type_name -> google.protobuf.Timestamp
18, // 7: engpad.v1.CreateShareLinkResponse.expires_at:type_name -> google.protobuf.Timestamp
17, // 8: engpad.v1.ListShareLinksResponse.links:type_name -> engpad.v1.ShareLinkInfo
18, // 9: engpad.v1.ShareLinkInfo.created_at:type_name -> google.protobuf.Timestamp
18, // 10: engpad.v1.ShareLinkInfo.expires_at:type_name -> google.protobuf.Timestamp
0, // 11: engpad.v1.EngPadSyncService.SyncNotebook:input_type -> engpad.v1.SyncNotebookRequest
4, // 12: engpad.v1.EngPadSyncService.GetNotebook:input_type -> engpad.v1.GetNotebookRequest
6, // 13: engpad.v1.EngPadSyncService.DeleteNotebook:input_type -> engpad.v1.DeleteNotebookRequest
8, // 14: engpad.v1.EngPadSyncService.ListNotebooks:input_type -> engpad.v1.ListNotebooksRequest
11, // 15: engpad.v1.EngPadSyncService.CreateShareLink:input_type -> engpad.v1.CreateShareLinkRequest
13, // 16: engpad.v1.EngPadSyncService.RevokeShareLink:input_type -> engpad.v1.RevokeShareLinkRequest
15, // 17: engpad.v1.EngPadSyncService.ListShareLinks:input_type -> engpad.v1.ListShareLinksRequest
3, // 18: engpad.v1.EngPadSyncService.SyncNotebook:output_type -> engpad.v1.SyncNotebookResponse
5, // 19: engpad.v1.EngPadSyncService.GetNotebook:output_type -> engpad.v1.GetNotebookResponse
7, // 20: engpad.v1.EngPadSyncService.DeleteNotebook:output_type -> engpad.v1.DeleteNotebookResponse
9, // 21: engpad.v1.EngPadSyncService.ListNotebooks:output_type -> engpad.v1.ListNotebooksResponse
12, // 22: engpad.v1.EngPadSyncService.CreateShareLink:output_type -> engpad.v1.CreateShareLinkResponse
14, // 23: engpad.v1.EngPadSyncService.RevokeShareLink:output_type -> engpad.v1.RevokeShareLinkResponse
16, // 24: engpad.v1.EngPadSyncService.ListShareLinks:output_type -> engpad.v1.ListShareLinksResponse
18, // [18:25] is the sub-list for method output_type
11, // [11:18] is the sub-list for method input_type
11, // [11:11] is the sub-list for extension type_name
11, // [11:11] is the sub-list for extension extendee
0, // [0:11] is the sub-list for field type_name
}
func init() { file_proto_engpad_v1_sync_proto_init() }
@@ -1017,7 +1162,7 @@ func file_proto_engpad_v1_sync_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_engpad_v1_sync_proto_rawDesc), len(file_proto_engpad_v1_sync_proto_rawDesc)),
NumEnums: 0,
NumMessages: 16,
NumMessages: 18,
NumExtensions: 0,
NumServices: 1,
},

View File

@@ -20,6 +20,7 @@ const _ = grpc.SupportPackageIsVersion9
const (
EngPadSyncService_SyncNotebook_FullMethodName = "/engpad.v1.EngPadSyncService/SyncNotebook"
EngPadSyncService_GetNotebook_FullMethodName = "/engpad.v1.EngPadSyncService/GetNotebook"
EngPadSyncService_DeleteNotebook_FullMethodName = "/engpad.v1.EngPadSyncService/DeleteNotebook"
EngPadSyncService_ListNotebooks_FullMethodName = "/engpad.v1.EngPadSyncService/ListNotebooks"
EngPadSyncService_CreateShareLink_FullMethodName = "/engpad.v1.EngPadSyncService/CreateShareLink"
@@ -32,6 +33,7 @@ const (
// 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 EngPadSyncServiceClient interface {
SyncNotebook(ctx context.Context, in *SyncNotebookRequest, opts ...grpc.CallOption) (*SyncNotebookResponse, error)
GetNotebook(ctx context.Context, in *GetNotebookRequest, opts ...grpc.CallOption) (*GetNotebookResponse, 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)
@@ -57,6 +59,16 @@ func (c *engPadSyncServiceClient) SyncNotebook(ctx context.Context, in *SyncNote
return out, nil
}
func (c *engPadSyncServiceClient) GetNotebook(ctx context.Context, in *GetNotebookRequest, opts ...grpc.CallOption) (*GetNotebookResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetNotebookResponse)
err := c.cc.Invoke(ctx, EngPadSyncService_GetNotebook_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *engPadSyncServiceClient) DeleteNotebook(ctx context.Context, in *DeleteNotebookRequest, opts ...grpc.CallOption) (*DeleteNotebookResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DeleteNotebookResponse)
@@ -112,6 +124,7 @@ func (c *engPadSyncServiceClient) ListShareLinks(ctx context.Context, in *ListSh
// for forward compatibility.
type EngPadSyncServiceServer interface {
SyncNotebook(context.Context, *SyncNotebookRequest) (*SyncNotebookResponse, error)
GetNotebook(context.Context, *GetNotebookRequest) (*GetNotebookResponse, error)
DeleteNotebook(context.Context, *DeleteNotebookRequest) (*DeleteNotebookResponse, error)
ListNotebooks(context.Context, *ListNotebooksRequest) (*ListNotebooksResponse, error)
CreateShareLink(context.Context, *CreateShareLinkRequest) (*CreateShareLinkResponse, error)
@@ -130,6 +143,9 @@ type UnimplementedEngPadSyncServiceServer struct{}
func (UnimplementedEngPadSyncServiceServer) SyncNotebook(context.Context, *SyncNotebookRequest) (*SyncNotebookResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SyncNotebook not implemented")
}
func (UnimplementedEngPadSyncServiceServer) GetNotebook(context.Context, *GetNotebookRequest) (*GetNotebookResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetNotebook not implemented")
}
func (UnimplementedEngPadSyncServiceServer) DeleteNotebook(context.Context, *DeleteNotebookRequest) (*DeleteNotebookResponse, error) {
return nil, status.Error(codes.Unimplemented, "method DeleteNotebook not implemented")
}
@@ -184,6 +200,24 @@ func _EngPadSyncService_SyncNotebook_Handler(srv interface{}, ctx context.Contex
return interceptor(ctx, in, info, handler)
}
func _EngPadSyncService_GetNotebook_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetNotebookRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(EngPadSyncServiceServer).GetNotebook(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: EngPadSyncService_GetNotebook_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(EngPadSyncServiceServer).GetNotebook(ctx, req.(*GetNotebookRequest))
}
return interceptor(ctx, in, info, handler)
}
func _EngPadSyncService_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 {
@@ -285,6 +319,10 @@ var EngPadSyncService_ServiceDesc = grpc.ServiceDesc{
MethodName: "SyncNotebook",
Handler: _EngPadSyncService_SyncNotebook_Handler,
},
{
MethodName: "GetNotebook",
Handler: _EngPadSyncService_GetNotebook_Handler,
},
{
MethodName: "DeleteNotebook",
Handler: _EngPadSyncService_DeleteNotebook_Handler,

View File

@@ -20,6 +20,7 @@ type Config struct {
type ServerConfig struct {
ListenAddr string `toml:"listen_addr"`
GRPCAddr string `toml:"grpc_addr"`
GRPCPlainAddr string `toml:"grpc_plain_addr"`
TLSCert string `toml:"tls_cert"`
TLSKey string `toml:"tls_key"`
}

View File

@@ -29,8 +29,8 @@ func AuthInterceptor(database *sql.DB) grpc.UnaryServerInterceptor {
return nil, status.Error(codes.Unauthenticated, "missing metadata")
}
usernames := md.Get("username")
passwords := md.Get("password")
usernames := md.Get("x-engpad-username")
passwords := md.Get("x-engpad-password")
if len(usernames) == 0 || len(passwords) == 0 {
return nil, status.Error(codes.Unauthenticated, "missing credentials")
}

View File

@@ -14,6 +14,7 @@ import (
type Config struct {
Addr string
PlainAddr string
TLSCert string
TLSKey string
DB *sql.DB
@@ -31,7 +32,7 @@ func Start(cfg Config) (*grpc.Server, error) {
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS13,
MinVersion: tls.VersionTLS12,
}
lis, err := net.Listen("tcp", cfg.Addr)
@@ -50,5 +51,19 @@ func Start(cfg Config) (*grpc.Server, error) {
slog.Info("gRPC server started", "addr", cfg.Addr)
go func() { _ = srv.Serve(lis) }()
// Optional plaintext listener for reverse proxy (e.g. nginx grpc_pass).
if cfg.PlainAddr != "" {
plainLis, err := net.Listen("tcp", cfg.PlainAddr)
if err != nil {
return nil, fmt.Errorf("listen %s: %w", cfg.PlainAddr, err)
}
plainSrv := grpc.NewServer(
grpc.UnaryInterceptor(AuthInterceptor(cfg.DB)),
)
pb.RegisterEngPadSyncServiceServer(plainSrv, syncSvc)
slog.Info("gRPC plaintext server started", "addr", cfg.PlainAddr)
go func() { _ = plainSrv.Serve(plainLis) }()
}
return srv, nil
}

View File

@@ -114,6 +114,71 @@ func (s *SyncService) SyncNotebook(ctx context.Context, req *pb.SyncNotebookRequ
}, nil
}
func (s *SyncService) GetNotebook(ctx context.Context, req *pb.GetNotebookRequest) (*pb.GetNotebookResponse, error) {
userID, ok := UserIDFromContext(ctx)
if !ok {
return nil, status.Error(codes.Internal, "missing user context")
}
var resp pb.GetNotebookResponse
var syncedAt int64
err := s.DB.QueryRowContext(ctx,
"SELECT id, remote_id, title, page_size, synced_at FROM notebooks WHERE id = ? AND user_id = ?",
req.NotebookId, userID,
).Scan(&resp.ServerNotebookId, &resp.RemoteId, &resp.Title, &resp.PageSize, &syncedAt)
if err == sql.ErrNoRows {
return nil, status.Error(codes.NotFound, "notebook not found")
}
if err != nil {
return nil, status.Errorf(codes.Internal, "query notebook: %v", err)
}
resp.SyncedAt = timestamppb.New(time.UnixMilli(syncedAt))
pageRows, err := s.DB.QueryContext(ctx,
"SELECT id, remote_id, page_number FROM pages WHERE notebook_id = ? ORDER BY page_number",
resp.ServerNotebookId,
)
if err != nil {
return nil, status.Errorf(codes.Internal, "query pages: %v", err)
}
defer func() { _ = pageRows.Close() }()
for pageRows.Next() {
var pageID, remoteID int64
var pageNum int32
if err := pageRows.Scan(&pageID, &remoteID, &pageNum); err != nil {
return nil, status.Errorf(codes.Internal, "scan page: %v", err)
}
pd := &pb.PageData{
PageId: remoteID,
PageNumber: pageNum,
}
strokeRows, err := s.DB.QueryContext(ctx,
"SELECT pen_size, color, style, point_data, stroke_order FROM strokes WHERE page_id = ? ORDER BY stroke_order",
pageID,
)
if err != nil {
return nil, status.Errorf(codes.Internal, "query strokes: %v", err)
}
for strokeRows.Next() {
var sd pb.StrokeData
if err := strokeRows.Scan(&sd.PenSize, &sd.Color, &sd.Style, &sd.PointData, &sd.StrokeOrder); err != nil {
_ = strokeRows.Close()
return nil, status.Errorf(codes.Internal, "scan stroke: %v", err)
}
pd.Strokes = append(pd.Strokes, &sd)
}
_ = strokeRows.Close()
resp.Pages = append(resp.Pages, pd)
}
return &resp, nil
}
func (s *SyncService) DeleteNotebook(ctx context.Context, req *pb.DeleteNotebookRequest) (*pb.DeleteNotebookResponse, error) {
userID, ok := UserIDFromContext(ctx)
if !ok {

View File

@@ -25,6 +25,7 @@ func (ws *WebServer) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
userID, err := auth.AuthenticateUser(ws.db, username, password)
if err != nil {
slog.Error("login failed", "username", username, "error", err)
ws.render(w, "login.html", map[string]string{"Error": "Invalid credentials"})
return
}
@@ -127,15 +128,21 @@ func (ws *WebServer) handleNotebook(w http.ResponseWriter, r *http.Request) {
}
pages = append(pages, pageInfo{
Number: num,
SVGLink: fmt.Sprintf("/v1/notebooks/%d/pages/%d/svg", id, num),
SVGLink: fmt.Sprintf("/notebooks/%d/pages/%d/svg", id, num),
ViewLink: fmt.Sprintf("/notebooks/%d/pages/%d", id, num),
})
}
// Load share links for this notebook.
shareLinks, _ := share.ListLinks(ws.db, id, ws.baseURL)
ws.render(w, "notebook.html", map[string]any{
"ID": id,
"Title": title,
"Pages": pages,
"PDFLink": fmt.Sprintf("/v1/notebooks/%d/pdf", id),
"PDFLink": fmt.Sprintf("/notebooks/%d/pdf", id),
"ShareLinks": shareLinks,
"BaseURL": ws.baseURL,
})
}
@@ -154,9 +161,9 @@ func (ws *WebServer) handlePage(w http.ResponseWriter, r *http.Request) {
"NotebookTitle": title,
"PageNumber": num,
"BackLink": fmt.Sprintf("/notebooks/%d", id),
"SVGLink": fmt.Sprintf("/v1/notebooks/%d/pages/%d/svg", id, num),
"JPGLink": fmt.Sprintf("/v1/notebooks/%d/pages/%d/jpg", id, num),
"PDFLink": fmt.Sprintf("/v1/notebooks/%d/pdf", id),
"SVGLink": fmt.Sprintf("/notebooks/%d/pages/%d/svg", id, num),
"JPGLink": fmt.Sprintf("/notebooks/%d/pages/%d/jpg", id, num),
"PDFLink": fmt.Sprintf("/notebooks/%d/pdf", id),
})
}
@@ -201,6 +208,7 @@ func (ws *WebServer) handleShareNotebook(w http.ResponseWriter, r *http.Request)
"Title": title,
"Pages": pages,
"PDFLink": fmt.Sprintf("/s/%s/pdf", token),
"Shared": true,
})
}
@@ -227,6 +235,45 @@ func (ws *WebServer) handleSharePage(w http.ResponseWriter, r *http.Request) {
})
}
// --- Share management ---
func (ws *WebServer) handleCreateShare(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
_, _, err := share.CreateLink(ws.db, id, 0, ws.baseURL) // no expiry
if err != nil {
slog.Error("create share link", "error", err)
http.Error(w, "Failed to create share link", http.StatusInternalServerError)
return
}
http.Redirect(w, r, fmt.Sprintf("/notebooks/%d", id), http.StatusFound)
}
func (ws *WebServer) handleDeleteNotebook(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value(userIDKey).(int64)
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
// Verify ownership before deleting.
var ownerID int64
err := ws.db.QueryRow("SELECT user_id FROM notebooks WHERE id = ?", id).Scan(&ownerID)
if err != nil || ownerID != userID {
http.Error(w, "Not found", http.StatusNotFound)
return
}
// CASCADE deletes pages, strokes, and share_links.
_, _ = ws.db.Exec("DELETE FROM notebooks WHERE id = ?", id)
http.Redirect(w, r, "/notebooks", http.StatusFound)
}
func (ws *WebServer) handleRevokeShare(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
token := r.FormValue("token")
if token != "" {
_ = share.RevokeLink(ws.db, token)
}
http.Redirect(w, r, fmt.Sprintf("/notebooks/%d", id), http.StatusFound)
}
// --- auth middleware ---
type ctxKey string
@@ -254,8 +301,14 @@ func (ws *WebServer) authMiddleware(next http.Handler) http.Handler {
}
func (ws *WebServer) render(w http.ResponseWriter, name string, data any) {
t, ok := ws.tmpls[name]
if !ok {
http.Error(w, "Template not found", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := ws.tmpl.ExecuteTemplate(w, name, data); err != nil {
if err := t.ExecuteTemplate(w, "layout.html", data); err != nil {
slog.Error("render template", "name", name, "error", err)
http.Error(w, "Template error", http.StatusInternalServerError)
}
}

View File

@@ -0,0 +1,232 @@
package webserver
import (
"fmt"
"net/http"
"strconv"
"git.wntrmute.dev/kyle/eng-pad-server/internal/render"
"git.wntrmute.dev/kyle/eng-pad-server/internal/share"
"github.com/go-chi/chi/v5"
)
// --- Authenticated render endpoints ---
func (ws *WebServer) handlePageSVG(w http.ResponseWriter, r *http.Request) {
notebookID, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
pageNum, _ := strconv.Atoi(chi.URLParam(r, "num"))
strokes, pageSize, err := ws.loadPageStrokes(notebookID, pageNum)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
svg, err := render.RenderSVG(pageSize, strokes)
if err != nil {
http.Error(w, "Render error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "image/svg+xml")
_, _ = w.Write([]byte(svg))
}
func (ws *WebServer) handlePageJPG(w http.ResponseWriter, r *http.Request) {
notebookID, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
pageNum, _ := strconv.Atoi(chi.URLParam(r, "num"))
strokes, pageSize, err := ws.loadPageStrokes(notebookID, pageNum)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
data, err := render.RenderJPG(pageSize, strokes, 95)
if err != nil {
http.Error(w, "Render error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=page-%d.jpg", pageNum))
_, _ = w.Write(data)
}
func (ws *WebServer) handleNotebookPDF(w http.ResponseWriter, r *http.Request) {
notebookID, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
pages, pageSize, err := ws.loadNotebookPages(notebookID)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
data, err := render.RenderPDF(pageSize, pages)
if err != nil {
http.Error(w, "Render error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Disposition", "attachment; filename=notebook.pdf")
_, _ = w.Write(data)
}
// --- Share render endpoints ---
func (ws *WebServer) handleSharePageSVG(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
notebookID, err := share.ValidateLink(ws.db, token)
if err != nil {
http.Error(w, "Link not found or expired", http.StatusGone)
return
}
pageNum, _ := strconv.Atoi(chi.URLParam(r, "num"))
strokes, pageSize, err := ws.loadPageStrokes(notebookID, pageNum)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
svg, err := render.RenderSVG(pageSize, strokes)
if err != nil {
http.Error(w, "Render error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "image/svg+xml")
_, _ = w.Write([]byte(svg))
}
func (ws *WebServer) handleSharePageJPG(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
notebookID, err := share.ValidateLink(ws.db, token)
if err != nil {
http.Error(w, "Link not found or expired", http.StatusGone)
return
}
pageNum, _ := strconv.Atoi(chi.URLParam(r, "num"))
strokes, pageSize, err := ws.loadPageStrokes(notebookID, pageNum)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
data, err := render.RenderJPG(pageSize, strokes, 95)
if err != nil {
http.Error(w, "Render error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=page-%d.jpg", pageNum))
_, _ = w.Write(data)
}
func (ws *WebServer) handleSharePDF(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
notebookID, err := share.ValidateLink(ws.db, token)
if err != nil {
http.Error(w, "Link not found or expired", http.StatusGone)
return
}
pages, pageSize, err := ws.loadNotebookPages(notebookID)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
data, err := render.RenderPDF(pageSize, pages)
if err != nil {
http.Error(w, "Render error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Disposition", "attachment; filename=notebook.pdf")
_, _ = w.Write(data)
}
// --- DB helpers ---
func (ws *WebServer) loadPageStrokes(notebookID int64, pageNum int) ([]render.Stroke, string, error) {
var pageSize string
err := ws.db.QueryRow("SELECT page_size FROM notebooks WHERE id = ?", notebookID).Scan(&pageSize)
if err != nil {
return nil, "", err
}
var pageID int64
err = ws.db.QueryRow(
"SELECT id FROM pages WHERE notebook_id = ? AND page_number = ?",
notebookID, pageNum,
).Scan(&pageID)
if err != nil {
return nil, "", err
}
rows, err := ws.db.Query(
"SELECT pen_size, color, style, point_data, stroke_order FROM strokes WHERE page_id = ? ORDER BY stroke_order",
pageID,
)
if err != nil {
return nil, "", err
}
defer func() { _ = rows.Close() }()
var strokes []render.Stroke
for rows.Next() {
var s render.Stroke
if err := rows.Scan(&s.PenSize, &s.Color, &s.Style, &s.PointData, &s.StrokeOrder); err != nil {
return nil, "", err
}
strokes = append(strokes, s)
}
return strokes, pageSize, nil
}
func (ws *WebServer) loadNotebookPages(notebookID int64) ([]render.Page, string, error) {
var pageSize string
err := ws.db.QueryRow("SELECT page_size FROM notebooks WHERE id = ?", notebookID).Scan(&pageSize)
if err != nil {
return nil, "", err
}
rows, err := ws.db.Query(
"SELECT id, page_number FROM pages WHERE notebook_id = ? ORDER BY page_number",
notebookID,
)
if err != nil {
return nil, "", err
}
defer func() { _ = rows.Close() }()
var pages []render.Page
for rows.Next() {
var pageID int64
var pageNum int
if err := rows.Scan(&pageID, &pageNum); err != nil {
return nil, "", err
}
strokeRows, err := ws.db.Query(
"SELECT pen_size, color, style, point_data, stroke_order FROM strokes WHERE page_id = ? ORDER BY stroke_order",
pageID,
)
if err != nil {
return nil, "", err
}
var strokes []render.Stroke
for strokeRows.Next() {
var s render.Stroke
if err := strokeRows.Scan(&s.PenSize, &s.Color, &s.Style, &s.PointData, &s.StrokeOrder); err != nil {
_ = strokeRows.Close()
return nil, "", err
}
strokes = append(strokes, s)
}
_ = strokeRows.Close()
pages = append(pages, render.Page{PageNumber: pageNum, Strokes: strokes})
}
return pages, pageSize, nil
}

View File

@@ -31,7 +31,7 @@ type Config struct {
type WebServer struct {
db *sql.DB
baseURL string
tmpl *template.Template
tmpls map[string]*template.Template
webauthn *webauthn.WebAuthn
mu sync.Mutex
sessions map[string]*webauthn.SessionData
@@ -43,15 +43,32 @@ func Start(cfg Config) (*http.Server, error) {
return nil, fmt.Errorf("template fs: %w", err)
}
tmpl, err := template.ParseFS(templateFS, "*.html")
layoutData, err := fs.ReadFile(templateFS, "layout.html")
if err != nil {
return nil, fmt.Errorf("parse templates: %w", err)
return nil, fmt.Errorf("read layout: %w", err)
}
pages := []string{"login.html", "notebooks.html", "notebook.html", "page.html", "keys.html"}
tmpls := make(map[string]*template.Template, len(pages))
for _, page := range pages {
t, err := template.New("layout.html").Parse(string(layoutData))
if err != nil {
return nil, fmt.Errorf("parse layout: %w", err)
}
pageData, err := fs.ReadFile(templateFS, page)
if err != nil {
return nil, fmt.Errorf("read %s: %w", page, err)
}
if _, err := t.Parse(string(pageData)); err != nil {
return nil, fmt.Errorf("parse %s: %w", page, err)
}
tmpls[page] = t
}
ws := &WebServer{
db: cfg.DB,
baseURL: cfg.BaseURL,
tmpl: tmpl,
tmpls: tmpls,
sessions: make(map[string]*webauthn.SessionData),
}
@@ -81,6 +98,9 @@ func Start(cfg Config) (*http.Server, error) {
// Share routes (no auth)
r.Get("/s/{token}", ws.handleShareNotebook)
r.Get("/s/{token}/pages/{num}", ws.handleSharePage)
r.Get("/s/{token}/pages/{num}/svg", ws.handleSharePageSVG)
r.Get("/s/{token}/pages/{num}/jpg", ws.handleSharePageJPG)
r.Get("/s/{token}/pdf", ws.handleSharePDF)
// Authenticated routes
r.Group(func(r chi.Router) {
@@ -89,6 +109,12 @@ func Start(cfg Config) (*http.Server, error) {
r.Get("/notebooks", ws.handleNotebooks)
r.Get("/notebooks/{id}", ws.handleNotebook)
r.Get("/notebooks/{id}/pages/{num}", ws.handlePage)
r.Get("/notebooks/{id}/pages/{num}/svg", ws.handlePageSVG)
r.Get("/notebooks/{id}/pages/{num}/jpg", ws.handlePageJPG)
r.Get("/notebooks/{id}/pdf", ws.handleNotebookPDF)
r.Post("/notebooks/{id}/delete", ws.handleDeleteNotebook)
r.Post("/notebooks/{id}/share", ws.handleCreateShare)
r.Post("/notebooks/{id}/share/revoke", ws.handleRevokeShare)
r.Get("/logout", ws.handleLogout)
// WebAuthn authenticated routes (registration + key management)

View File

@@ -9,6 +9,7 @@ import "google/protobuf/timestamp.proto";
service EngPadSyncService {
rpc SyncNotebook(SyncNotebookRequest) returns (SyncNotebookResponse);
rpc GetNotebook(GetNotebookRequest) returns (GetNotebookResponse);
rpc DeleteNotebook(DeleteNotebookRequest) returns (DeleteNotebookResponse);
rpc ListNotebooks(ListNotebooksRequest) returns (ListNotebooksResponse);
rpc CreateShareLink(CreateShareLinkRequest) returns (CreateShareLinkResponse);
@@ -42,6 +43,19 @@ message SyncNotebookResponse {
google.protobuf.Timestamp synced_at = 2;
}
message GetNotebookRequest {
int64 notebook_id = 1; // Server-side notebook ID
}
message GetNotebookResponse {
int64 server_notebook_id = 1;
int64 remote_id = 2;
string title = 3;
string page_size = 4;
repeated PageData pages = 5;
google.protobuf.Timestamp synced_at = 6;
}
message DeleteNotebookRequest {
int64 notebook_id = 1;
}

View File

@@ -18,9 +18,9 @@
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; }
.page-thumb { border: 1px solid #ccc; background: #fff; aspect-ratio: 0.773; display: flex; align-items: center; justify-content: center; }
.page-thumb img { width: 100%; height: 100%; object-fit: contain; }
.btn { display: inline-block; padding: 0.5rem 1rem; border: 1px solid #111; border-radius: 4px; text-decoration: none; color: #111; background: #fff; cursor: pointer; }
.btn { display: inline-flex; align-items: center; padding: 0.5rem 1rem; border: 1px solid #111; border-radius: 4px; text-decoration: none; color: #111; background: #fff; cursor: pointer; font: inherit; font-size: 1rem; line-height: 1.5; box-sizing: border-box; }
.btn:hover { background: #f0f0f0; }
.actions { display: flex; gap: 0.5rem; margin: 1rem 0; }
.actions { display: flex; align-items: center; gap: 0.5rem; margin: 1rem 0; }
input[type="text"], input[type="password"] { padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; width: 100%; max-width: 300px; }
label { display: block; margin-bottom: 0.25rem; font-weight: bold; }
.form-group { margin-bottom: 1rem; }

View File

@@ -4,7 +4,32 @@
<h1>{{.Title}}</h1>
<div class="actions">
<a href="{{.PDFLink}}" class="btn">Download PDF</a>
{{if not .Shared}}
<form method="POST" action="/notebooks/{{.ID}}/share" style="display:inline;">
<button type="submit" class="btn">Share</button>
</form>
<form method="POST" action="/notebooks/{{.ID}}/delete" style="display:inline;"
onsubmit="return confirm('Delete this notebook from the server?');">
<button type="submit" class="btn" style="color: #c62828; border-color: #c62828;">Delete</button>
</form>
{{end}}
</div>
{{if .ShareLinks}}
<div style="margin: 1rem 0; padding: 0.75rem; border: 1px solid #ccc; border-radius: 4px;">
<strong>Share Links</strong>
{{range .ShareLinks}}
<div style="display: flex; align-items: center; gap: 0.5rem; margin-top: 0.5rem;">
<code style="font-size: 0.85rem; flex: 1;"><a href="{{.URL}}">{{.URL}}</a></code>
<form method="POST" action="/notebooks/{{$.ID}}/share/revoke" style="margin:0;">
<input type="hidden" name="token" value="{{.Token}}">
<button type="submit" class="btn" style="font-size: 0.8rem; padding: 0.25rem 0.5rem;">Revoke</button>
</form>
</div>
{{end}}
</div>
{{end}}
<div class="grid">
{{range .Pages}}
<div>