Security hardening: fix critical, high, and medium issues from audit
CRITICAL: - A-001: SQL injection in snapshot — escape single quotes in backup path - A-002: Timing attack — always verify against dummy hash when user not found, preventing username enumeration - A-003: Notebook ownership — all authenticated endpoints now verify user_id before loading notebook data - A-004: Point data bounds — decodePoints returns error on misaligned data, >4MB payloads, and NaN/Inf values HIGH: - A-005: Error messages — generic errors in HTTP responses, no err.Error() - A-006: Share link authz — RevokeShareLink verifies notebook ownership - A-007: Scan errors — return 500 instead of silently continuing MEDIUM: - A-008: Web server TLS — optional TLS support (HTTPS when configured) - A-009: Input validation — page_size, stroke count, point_data alignment checked in SyncNotebook RPC - A-010: Graceful shutdown — 30s drain on SIGINT/SIGTERM, all servers shut down properly Added AUDIT.md with all 17 findings, status, and rationale for accepted risks. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
179
AUDIT.md
Normal file
179
AUDIT.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# AUDIT.md — eng-pad-server Security Audit
|
||||||
|
|
||||||
|
## Audit Date: 2026-03-24
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Comprehensive security and engineering review of the eng-pad-server
|
||||||
|
codebase. 17 issues identified across critical, high, and medium
|
||||||
|
severity levels. All critical and high issues resolved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### A-001: SQL Injection in Database Backup
|
||||||
|
- **Severity**: Critical
|
||||||
|
- **Status**: ~~Resolved~~
|
||||||
|
- **Location**: `cmd/eng-pad-server/snapshot.go`
|
||||||
|
- **Description**: Backup path interpolated into SQL via `fmt.Sprintf`
|
||||||
|
without escaping. Allows arbitrary SQL execution if path contains
|
||||||
|
single quotes.
|
||||||
|
- **Resolution**: Escape single quotes in the backup path before
|
||||||
|
interpolation.
|
||||||
|
|
||||||
|
### A-002: Authentication Timing Attack
|
||||||
|
- **Severity**: High
|
||||||
|
- **Status**: ~~Resolved~~
|
||||||
|
- **Location**: `internal/auth/users.go`
|
||||||
|
- **Description**: Early return when user not found skips Argon2id
|
||||||
|
computation, allowing username enumeration via response timing.
|
||||||
|
- **Resolution**: Always perform Argon2id verification against a dummy
|
||||||
|
hash when user is not found, consuming constant time.
|
||||||
|
|
||||||
|
### A-003: Missing Notebook Ownership Checks
|
||||||
|
- **Severity**: High
|
||||||
|
- **Status**: ~~Resolved~~
|
||||||
|
- **Location**: `internal/server/notebooks_handler.go`
|
||||||
|
- **Description**: Authenticated endpoints load notebooks by ID without
|
||||||
|
verifying the notebook belongs to the requesting user. An
|
||||||
|
authenticated user could access any notebook by guessing IDs.
|
||||||
|
- **Resolution**: Added `user_id` check to all notebook queries in
|
||||||
|
authenticated endpoints. Share link endpoints use the validated
|
||||||
|
notebook ID from the share link.
|
||||||
|
|
||||||
|
### A-004: Unbounded Point Data Decoding
|
||||||
|
- **Severity**: High
|
||||||
|
- **Status**: ~~Resolved~~
|
||||||
|
- **Location**: `internal/render/svg.go`
|
||||||
|
- **Description**: `decodePoints()` panics on data not aligned to 4
|
||||||
|
bytes, has no size limit (OOM risk), and doesn't filter NaN/Inf.
|
||||||
|
- **Resolution**: Added alignment check, 4MB size limit, NaN/Inf
|
||||||
|
filtering. Function now returns error.
|
||||||
|
|
||||||
|
### A-005: Error Messages Leak Information
|
||||||
|
- **Severity**: High
|
||||||
|
- **Status**: ~~Resolved~~
|
||||||
|
- **Location**: Multiple REST handlers
|
||||||
|
- **Description**: `err.Error()` concatenated directly into HTTP JSON
|
||||||
|
responses, leaking database structure and internal state.
|
||||||
|
- **Resolution**: All error responses use generic messages. Detailed
|
||||||
|
errors logged server-side only.
|
||||||
|
|
||||||
|
### A-006: No Authorization on Share Link Revocation
|
||||||
|
- **Severity**: High
|
||||||
|
- **Status**: ~~Resolved~~
|
||||||
|
- **Location**: `internal/grpcserver/share.go`
|
||||||
|
- **Description**: Any authenticated user could revoke any share link
|
||||||
|
by token, regardless of notebook ownership.
|
||||||
|
- **Resolution**: Added JOIN with notebooks table to verify the calling
|
||||||
|
user owns the notebook before allowing revocation.
|
||||||
|
|
||||||
|
### A-007: Silent Row Scan Errors
|
||||||
|
- **Severity**: High
|
||||||
|
- **Status**: ~~Resolved~~
|
||||||
|
- **Location**: Multiple handlers
|
||||||
|
- **Description**: `continue` on row scan errors silently returns
|
||||||
|
incomplete data without indication.
|
||||||
|
- **Resolution**: Scan errors now return 500 Internal Server Error in
|
||||||
|
REST handlers.
|
||||||
|
|
||||||
|
### A-008: Web Server Missing TLS
|
||||||
|
- **Severity**: Medium
|
||||||
|
- **Status**: ~~Resolved~~
|
||||||
|
- **Location**: `internal/webserver/server.go`
|
||||||
|
- **Description**: Web UI served over plain HTTP. Session cookies marked
|
||||||
|
`Secure: true` are ineffective without TLS.
|
||||||
|
- **Resolution**: Added TLS cert/key fields to web server config. Uses
|
||||||
|
HTTPS when configured, falls back to HTTP for development.
|
||||||
|
|
||||||
|
### A-009: Missing Input Validation in Sync RPC
|
||||||
|
- **Severity**: Medium
|
||||||
|
- **Status**: ~~Resolved~~
|
||||||
|
- **Location**: `internal/grpcserver/sync.go`
|
||||||
|
- **Description**: No validation of page_size, stroke count, or point
|
||||||
|
data alignment in SyncNotebook RPC.
|
||||||
|
- **Resolution**: Added validation: page_size must be REGULAR or LARGE,
|
||||||
|
total strokes limited to 100,000, point_data must be 4-byte aligned.
|
||||||
|
|
||||||
|
### A-010: No Graceful Shutdown
|
||||||
|
- **Severity**: Medium
|
||||||
|
- **Status**: ~~Resolved~~
|
||||||
|
- **Location**: `cmd/eng-pad-server/server.go`
|
||||||
|
- **Description**: Signal handler terminates immediately without
|
||||||
|
draining in-flight requests.
|
||||||
|
- **Resolution**: Graceful shutdown with 30-second timeout: gRPC
|
||||||
|
GracefulStop, HTTP Shutdown, database Close.
|
||||||
|
|
||||||
|
### A-011: Missing CSRF Protection on Web Forms
|
||||||
|
- **Severity**: Medium
|
||||||
|
- **Status**: Accepted
|
||||||
|
- **Rationale**: Web UI is currently read-only (viewing synced
|
||||||
|
notebooks). The only mutating form is login, which is not a CSRF
|
||||||
|
target (attacker gains nothing by logging victim into their own
|
||||||
|
account). Will add CSRF tokens when/if web UI gains write features.
|
||||||
|
|
||||||
|
### A-012: WebAuthn Sign Count Not Verified
|
||||||
|
- **Severity**: Medium
|
||||||
|
- **Status**: Accepted
|
||||||
|
- **Rationale**: Sign count regression detection is defense-in-depth
|
||||||
|
against cloned authenticators. Risk is low for a personal service.
|
||||||
|
Will add verification when WebAuthn is fully wired into the web UI.
|
||||||
|
|
||||||
|
### A-013: gRPC Per-Request Password Auth
|
||||||
|
- **Severity**: Medium (audit assessment) / Accepted (our assessment)
|
||||||
|
- **Status**: Accepted
|
||||||
|
- **Rationale**: By design. Password travels over TLS 1.3 (encrypted),
|
||||||
|
stored in Android Keystore (hardware-backed). Sync is manual and
|
||||||
|
infrequent. Token-based auth adds complexity without meaningful
|
||||||
|
security gain for this use case.
|
||||||
|
|
||||||
|
### A-014: No Structured Logging
|
||||||
|
- **Severity**: Medium
|
||||||
|
- **Status**: Open
|
||||||
|
- **Description**: Only `fmt.Printf` to stdout. No log levels, no
|
||||||
|
structured output, no request tracking.
|
||||||
|
- **Plan**: Add `log/slog` based logging in a future phase.
|
||||||
|
|
||||||
|
### A-015: Incomplete Config Validation
|
||||||
|
- **Severity**: Medium
|
||||||
|
- **Status**: Open
|
||||||
|
- **Description**: TLS files not checked for existence at startup.
|
||||||
|
Token TTL, WebAuthn config not validated.
|
||||||
|
- **Plan**: Add file existence checks and config field validation.
|
||||||
|
|
||||||
|
### A-016: Inconsistent Error Types
|
||||||
|
- **Severity**: Low
|
||||||
|
- **Status**: Open
|
||||||
|
- **Description**: String errors instead of sentinel errors make
|
||||||
|
error handling difficult for callers.
|
||||||
|
|
||||||
|
### A-017: No Race Condition Testing
|
||||||
|
- **Severity**: Low
|
||||||
|
- **Status**: Open
|
||||||
|
- **Description**: Test suite does not use `-race` flag.
|
||||||
|
- **Plan**: Add `make test-race` target.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priority Summary
|
||||||
|
|
||||||
|
| ID | Severity | Status |
|
||||||
|
|----|----------|--------|
|
||||||
|
| A-001 | Critical | ~~Resolved~~ |
|
||||||
|
| A-002 | High | ~~Resolved~~ |
|
||||||
|
| A-003 | High | ~~Resolved~~ |
|
||||||
|
| A-004 | High | ~~Resolved~~ |
|
||||||
|
| A-005 | High | ~~Resolved~~ |
|
||||||
|
| A-006 | High | ~~Resolved~~ |
|
||||||
|
| A-007 | High | ~~Resolved~~ |
|
||||||
|
| A-008 | Medium | ~~Resolved~~ |
|
||||||
|
| A-009 | Medium | ~~Resolved~~ |
|
||||||
|
| A-010 | Medium | ~~Resolved~~ |
|
||||||
|
| A-011 | Medium | Accepted |
|
||||||
|
| A-012 | Medium | Accepted |
|
||||||
|
| A-013 | Medium | Accepted |
|
||||||
|
| A-014 | Medium | Open |
|
||||||
|
| A-015 | Medium | Open |
|
||||||
|
| A-016 | Low | Open |
|
||||||
|
| A-017 | Low | Open |
|
||||||
@@ -2,10 +2,16 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// dummyHash is a pre-computed Argon2id hash used for constant-time comparison
|
||||||
|
// when a user is not found. This prevents timing attacks that reveal whether
|
||||||
|
// a username exists.
|
||||||
|
var dummyHash = "$argon2id$v=19$m=65536,t=3,p=4$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||||
|
|
||||||
// CreateUser creates a new user with a hashed password.
|
// CreateUser creates a new user with a hashed password.
|
||||||
func CreateUser(database *sql.DB, username, password string, params Argon2Params) (int64, error) {
|
func CreateUser(database *sql.DB, username, password string, params Argon2Params) (int64, error) {
|
||||||
hash, err := HashPassword(password, params)
|
hash, err := HashPassword(password, params)
|
||||||
@@ -32,16 +38,22 @@ func AuthenticateUser(database *sql.DB, username, password string) (int64, error
|
|||||||
err := database.QueryRow(
|
err := database.QueryRow(
|
||||||
"SELECT id, password_hash FROM users WHERE username = ?", username,
|
"SELECT id, password_hash FROM users WHERE username = ?", username,
|
||||||
).Scan(&userID, &hash)
|
).Scan(&userID, &hash)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
// User not found: verify against dummy hash to consume constant time,
|
||||||
|
// preventing timing attacks that reveal username existence.
|
||||||
|
_, _ = VerifyPassword(password, dummyHash)
|
||||||
|
return 0, fmt.Errorf("invalid credentials")
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("user not found")
|
return 0, fmt.Errorf("invalid credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
ok, err := VerifyPassword(password, hash)
|
ok, err := VerifyPassword(password, hash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, fmt.Errorf("invalid credentials")
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
return 0, fmt.Errorf("invalid password")
|
return 0, fmt.Errorf("invalid credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
return userID, nil
|
return userID, nil
|
||||||
|
|||||||
@@ -19,10 +19,13 @@ type Config struct {
|
|||||||
BaseURL string
|
BaseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Start(cfg Config) error {
|
// Start creates and starts the gRPC server. It returns the server so the
|
||||||
|
// caller can manage graceful shutdown. The server runs in a background
|
||||||
|
// goroutine; errors are sent to errCh.
|
||||||
|
func Start(cfg Config) (*grpc.Server, error) {
|
||||||
cert, err := tls.LoadX509KeyPair(cfg.TLSCert, cfg.TLSKey)
|
cert, err := tls.LoadX509KeyPair(cfg.TLSCert, cfg.TLSKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load TLS cert: %w", err)
|
return nil, fmt.Errorf("load TLS cert: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tlsConfig := &tls.Config{
|
tlsConfig := &tls.Config{
|
||||||
@@ -32,7 +35,7 @@ func Start(cfg Config) error {
|
|||||||
|
|
||||||
lis, err := net.Listen("tcp", cfg.Addr)
|
lis, err := net.Listen("tcp", cfg.Addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("listen %s: %w", cfg.Addr, err)
|
return nil, fmt.Errorf("listen %s: %w", cfg.Addr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
srv := grpc.NewServer(
|
srv := grpc.NewServer(
|
||||||
@@ -44,5 +47,7 @@ func Start(cfg Config) error {
|
|||||||
pb.RegisterEngPadSyncServer(srv, syncSvc)
|
pb.RegisterEngPadSyncServer(srv, syncSvc)
|
||||||
|
|
||||||
fmt.Printf("gRPC listening on %s\n", cfg.Addr)
|
fmt.Printf("gRPC listening on %s\n", cfg.Addr)
|
||||||
return srv.Serve(lis)
|
go func() { _ = srv.Serve(lis) }()
|
||||||
|
|
||||||
|
return srv, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,23 @@ func (s *SyncService) CreateShareLink(ctx context.Context, req *pb.CreateShareLi
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *SyncService) RevokeShareLink(ctx context.Context, req *pb.RevokeShareLinkRequest) (*pb.RevokeShareLinkResponse, error) {
|
func (s *SyncService) RevokeShareLink(ctx context.Context, req *pb.RevokeShareLinkRequest) (*pb.RevokeShareLinkResponse, error) {
|
||||||
|
userID, ok := UserIDFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return nil, status.Error(codes.Internal, "missing user context")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the calling user owns the notebook associated with this share link.
|
||||||
|
var count int
|
||||||
|
err := s.DB.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM share_links sl
|
||||||
|
JOIN notebooks n ON sl.notebook_id = n.id
|
||||||
|
WHERE sl.token = ? AND n.user_id = ?`,
|
||||||
|
req.Token, userID,
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil || count == 0 {
|
||||||
|
return nil, status.Error(codes.NotFound, "share link not found")
|
||||||
|
}
|
||||||
|
|
||||||
if err := share.RevokeLink(s.DB, req.Token); err != nil {
|
if err := share.RevokeLink(s.DB, req.Token); err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "revoke: %v", err)
|
return nil, status.Errorf(codes.Internal, "revoke: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,33 @@ type SyncService struct {
|
|||||||
BaseURL string
|
BaseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maxTotalStrokes = 100000
|
||||||
|
|
||||||
func (s *SyncService) SyncNotebook(ctx context.Context, req *pb.SyncNotebookRequest) (*pb.SyncNotebookResponse, error) {
|
func (s *SyncService) SyncNotebook(ctx context.Context, req *pb.SyncNotebookRequest) (*pb.SyncNotebookResponse, error) {
|
||||||
userID, ok := UserIDFromContext(ctx)
|
userID, ok := UserIDFromContext(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, status.Error(codes.Internal, "missing user context")
|
return nil, status.Error(codes.Internal, "missing user context")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate page_size
|
||||||
|
if req.PageSize != "REGULAR" && req.PageSize != "LARGE" {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "invalid page_size: must be REGULAR or LARGE")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate total stroke count and point_data alignment
|
||||||
|
totalStrokes := 0
|
||||||
|
for _, page := range req.Pages {
|
||||||
|
totalStrokes += len(page.Strokes)
|
||||||
|
if totalStrokes > maxTotalStrokes {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "total stroke count exceeds maximum of %d", maxTotalStrokes)
|
||||||
|
}
|
||||||
|
for _, stroke := range page.Strokes {
|
||||||
|
if len(stroke.PointData)%4 != 0 {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "point_data length must be a multiple of 4")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tx, err := s.DB.BeginTx(ctx, nil)
|
tx, err := s.DB.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "begin tx: %v", err)
|
return nil, status.Errorf(codes.Internal, "begin tx: %v", err)
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ func RenderJPG(pageSize string, strokes []Stroke, quality int) ([]byte, error) {
|
|||||||
|
|
||||||
// Draw strokes
|
// Draw strokes
|
||||||
for _, s := range strokes {
|
for _, s := range strokes {
|
||||||
points := decodePoints(s.PointData)
|
points, err := decodePoints(s.PointData)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if len(points) < 4 {
|
if len(points) < 4 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ type Page struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RenderPDF generates a minimal PDF document from pages.
|
// RenderPDF generates a minimal PDF document from pages.
|
||||||
// Uses raw PDF operators — no external library needed.
|
// Uses raw PDF operators -- no external library needed.
|
||||||
func RenderPDF(pageSize string, pages []Page) []byte {
|
func RenderPDF(pageSize string, pages []Page) ([]byte, error) {
|
||||||
w, h := PageSizePt(pageSize)
|
w, h := PageSizePt(pageSize)
|
||||||
|
|
||||||
var objects []string
|
var objects []string
|
||||||
@@ -30,7 +30,7 @@ func RenderPDF(pageSize string, pages []Page) []byte {
|
|||||||
objects = append(objects, fmt.Sprintf("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"))
|
objects = append(objects, fmt.Sprintf("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"))
|
||||||
pdf.WriteString(objects[0])
|
pdf.WriteString(objects[0])
|
||||||
|
|
||||||
// Pages object (object 2) — we'll write this after we know the page objects
|
// Pages object (object 2) -- we'll write this after we know the page objects
|
||||||
pagesObjOffset := pdf.Len()
|
pagesObjOffset := pdf.Len()
|
||||||
pagesObjPlaceholder := strings.Repeat(" ", 200) + "\n"
|
pagesObjPlaceholder := strings.Repeat(" ", 200) + "\n"
|
||||||
pdf.WriteString(pagesObjPlaceholder)
|
pdf.WriteString(pagesObjPlaceholder)
|
||||||
@@ -47,7 +47,10 @@ func RenderPDF(pageSize string, pages []Page) []byte {
|
|||||||
// Content stream
|
// Content stream
|
||||||
var stream strings.Builder
|
var stream strings.Builder
|
||||||
for _, s := range page.Strokes {
|
for _, s := range page.Strokes {
|
||||||
points := decodePoints(s.PointData)
|
points, err := decodePoints(s.PointData)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if len(points) < 4 {
|
if len(points) < 4 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -89,12 +92,12 @@ func RenderPDF(pageSize string, pages []Page) []byte {
|
|||||||
// Now write the Pages object at its placeholder position
|
// Now write the Pages object at its placeholder position
|
||||||
pagesObj := fmt.Sprintf("2 0 obj\n<< /Type /Pages /Kids [%s] /Count %d >>\nendobj\n",
|
pagesObj := fmt.Sprintf("2 0 obj\n<< /Type /Pages /Kids [%s] /Count %d >>\nendobj\n",
|
||||||
strings.Join(pageRefs, " "), len(pages))
|
strings.Join(pageRefs, " "), len(pages))
|
||||||
// Overwrite placeholder — we need to rebuild the PDF string
|
// Overwrite placeholder -- we need to rebuild the PDF string
|
||||||
pdfStr := pdf.String()
|
pdfStr := pdf.String()
|
||||||
pdfStr = pdfStr[:pagesObjOffset] + pagesObj + strings.Repeat(" ", len(pagesObjPlaceholder)-len(pagesObj)) + pdfStr[pagesObjOffset+len(pagesObjPlaceholder):]
|
pdfStr = pdfStr[:pagesObjOffset] + pagesObj + strings.Repeat(" ", len(pagesObjPlaceholder)-len(pagesObj)) + pdfStr[pagesObjOffset+len(pagesObjPlaceholder):]
|
||||||
|
|
||||||
// Rebuild with correct offsets for xref
|
// Rebuild with correct offsets for xref
|
||||||
// For simplicity, just return the PDF bytes — most viewers handle minor xref issues
|
// For simplicity, just return the PDF bytes -- most viewers handle minor xref issues
|
||||||
var final strings.Builder
|
var final strings.Builder
|
||||||
final.WriteString(pdfStr)
|
final.WriteString(pdfStr)
|
||||||
|
|
||||||
@@ -102,7 +105,7 @@ func RenderPDF(pageSize string, pages []Page) []byte {
|
|||||||
xrefOffset := final.Len()
|
xrefOffset := final.Len()
|
||||||
fmt.Fprintf(&final, "xref\n0 %d\n", nextObj)
|
fmt.Fprintf(&final, "xref\n0 %d\n", nextObj)
|
||||||
fmt.Fprintf(&final, "0000000000 65535 f \n")
|
fmt.Fprintf(&final, "0000000000 65535 f \n")
|
||||||
// For a proper PDF we'd need exact offsets — skip for now
|
// For a proper PDF we'd need exact offsets -- skip for now
|
||||||
for i := 0; i < nextObj-1; i++ {
|
for i := 0; i < nextObj-1; i++ {
|
||||||
fmt.Fprintf(&final, "%010d 00000 n \n", 0)
|
fmt.Fprintf(&final, "%010d 00000 n \n", 0)
|
||||||
}
|
}
|
||||||
@@ -110,5 +113,5 @@ func RenderPDF(pageSize string, pages []Page) []byte {
|
|||||||
fmt.Fprintf(&final, "trailer\n<< /Size %d /Root 1 0 R >>\n", nextObj)
|
fmt.Fprintf(&final, "trailer\n<< /Size %d /Root 1 0 R >>\n", nextObj)
|
||||||
fmt.Fprintf(&final, "startxref\n%d\n%%%%EOF\n", xrefOffset)
|
fmt.Fprintf(&final, "startxref\n%d\n%%%%EOF\n", xrefOffset)
|
||||||
|
|
||||||
return []byte(final.String())
|
return []byte(final.String()), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ func TestRenderSVG(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
svg := RenderSVG("REGULAR", strokes)
|
svg, err := RenderSVG("REGULAR", strokes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
if !strings.Contains(svg, "<svg") {
|
if !strings.Contains(svg, "<svg") {
|
||||||
t.Fatal("expected SVG element")
|
t.Fatal("expected SVG element")
|
||||||
@@ -51,7 +54,10 @@ func TestRenderSVGDashed(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
svg := RenderSVG("REGULAR", strokes)
|
svg, err := RenderSVG("REGULAR", strokes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render: %v", err)
|
||||||
|
}
|
||||||
if !strings.Contains(svg, "stroke-dasharray") {
|
if !strings.Contains(svg, "stroke-dasharray") {
|
||||||
t.Fatal("expected stroke-dasharray for dashed line")
|
t.Fatal("expected stroke-dasharray for dashed line")
|
||||||
}
|
}
|
||||||
@@ -67,7 +73,10 @@ func TestRenderSVGArrow(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
svg := RenderSVG("REGULAR", strokes)
|
svg, err := RenderSVG("REGULAR", strokes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render: %v", err)
|
||||||
|
}
|
||||||
if !strings.Contains(svg, "<line") {
|
if !strings.Contains(svg, "<line") {
|
||||||
t.Fatal("expected arrow head lines")
|
t.Fatal("expected arrow head lines")
|
||||||
}
|
}
|
||||||
@@ -111,7 +120,10 @@ func TestRenderPDF(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
data := RenderPDF("REGULAR", pages)
|
data, err := RenderPDF("REGULAR", pages)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render: %v", err)
|
||||||
|
}
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
t.Fatal("expected non-empty PDF")
|
t.Fatal("expected non-empty PDF")
|
||||||
}
|
}
|
||||||
@@ -130,3 +142,29 @@ func TestPageSizePt(t *testing.T) {
|
|||||||
t.Fatalf("LARGE: got %v x %v, want 792 x 1224", w, h)
|
t.Fatalf("LARGE: got %v x %v, want 792 x 1224", w, h)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDecodePointsInvalidLength(t *testing.T) {
|
||||||
|
// Not a multiple of 4
|
||||||
|
data := []byte{1, 2, 3}
|
||||||
|
_, err := decodePoints(data)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-multiple-of-4 data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodePointsNaN(t *testing.T) {
|
||||||
|
data := make([]byte, 4)
|
||||||
|
binary.LittleEndian.PutUint32(data, math.Float32bits(float32(math.NaN())))
|
||||||
|
_, err := decodePoints(data)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for NaN point data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodePointsOversize(t *testing.T) {
|
||||||
|
data := make([]byte, maxPointDataSize+4)
|
||||||
|
_, err := decodePoints(data)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for oversized point data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import (
|
|||||||
|
|
||||||
const canonicalToPDF = 72.0 / 300.0 // 0.24
|
const canonicalToPDF = 72.0 / 300.0 // 0.24
|
||||||
|
|
||||||
|
// maxPointDataSize is the maximum allowed size of point data (4 MB).
|
||||||
|
const maxPointDataSize = 4 * 1024 * 1024
|
||||||
|
|
||||||
// PageSizePt returns the page dimensions in PDF points (72 DPI).
|
// PageSizePt returns the page dimensions in PDF points (72 DPI).
|
||||||
func PageSizePt(pageSize string) (float64, float64) {
|
func PageSizePt(pageSize string) (float64, float64) {
|
||||||
switch pageSize {
|
switch pageSize {
|
||||||
@@ -20,15 +23,15 @@ func PageSizePt(pageSize string) (float64, float64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Stroke struct {
|
type Stroke struct {
|
||||||
PenSize float32
|
PenSize float32
|
||||||
Color int32
|
Color int32
|
||||||
Style string
|
Style string
|
||||||
PointData []byte
|
PointData []byte
|
||||||
StrokeOrder int
|
StrokeOrder int
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenderSVG renders a page's strokes as an SVG document.
|
// RenderSVG renders a page's strokes as an SVG document.
|
||||||
func RenderSVG(pageSize string, strokes []Stroke) string {
|
func RenderSVG(pageSize string, strokes []Stroke) (string, error) {
|
||||||
w, h := PageSizePt(pageSize)
|
w, h := PageSizePt(pageSize)
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|
||||||
@@ -39,7 +42,10 @@ func RenderSVG(pageSize string, strokes []Stroke) string {
|
|||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
|
|
||||||
for _, s := range strokes {
|
for _, s := range strokes {
|
||||||
points := decodePoints(s.PointData)
|
points, err := decodePoints(s.PointData)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if len(points) < 4 {
|
if len(points) < 4 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -71,7 +77,7 @@ func RenderSVG(pageSize string, strokes []Stroke) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
b.WriteString("</svg>")
|
b.WriteString("</svg>")
|
||||||
return b.String()
|
return b.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderArrowHeads(x1, y1, x2, y2 float64, style string, penW float64, color string) string {
|
func renderArrowHeads(x1, y1, x2, y2 float64, style string, penW float64, color string) string {
|
||||||
@@ -109,14 +115,28 @@ func renderArrowHeads(x1, y1, x2, y2 float64, style string, penW float64, color
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodePoints(data []byte) []float64 {
|
// decodePoints decodes a byte slice of little-endian float32 values into float64.
|
||||||
|
// Returns an error if the data length is not a multiple of 4, exceeds maxPointDataSize,
|
||||||
|
// or contains NaN/Inf values.
|
||||||
|
func decodePoints(data []byte) ([]float64, error) {
|
||||||
|
if len(data)%4 != 0 {
|
||||||
|
return nil, fmt.Errorf("point data length %d is not a multiple of 4", len(data))
|
||||||
|
}
|
||||||
|
if len(data) > maxPointDataSize {
|
||||||
|
return nil, fmt.Errorf("point data size %d exceeds maximum %d", len(data), maxPointDataSize)
|
||||||
|
}
|
||||||
|
|
||||||
count := len(data) / 4
|
count := len(data) / 4
|
||||||
points := make([]float64, count)
|
points := make([]float64, 0, count)
|
||||||
for i := 0; i < count; i++ {
|
for i := 0; i < count; i++ {
|
||||||
bits := binary.LittleEndian.Uint32(data[i*4 : (i+1)*4])
|
bits := binary.LittleEndian.Uint32(data[i*4 : (i+1)*4])
|
||||||
points[i] = float64(math.Float32frombits(bits))
|
v := float64(math.Float32frombits(bits))
|
||||||
|
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||||||
|
return nil, fmt.Errorf("point data contains NaN or Inf at index %d", i)
|
||||||
|
}
|
||||||
|
points = append(points, v)
|
||||||
}
|
}
|
||||||
return points
|
return points, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func colorToCSS(argb int32) string {
|
func colorToCSS(argb int32) string {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
@@ -41,7 +42,8 @@ func handleListNotebooks(database *sql.DB) http.HandlerFunc {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var nb notebookJSON
|
var nb notebookJSON
|
||||||
if err := rows.Scan(&nb.ID, &nb.RemoteID, &nb.Title, &nb.PageSize, &nb.Pages); err != nil {
|
if err := rows.Scan(&nb.ID, &nb.RemoteID, &nb.Title, &nb.PageSize, &nb.Pages); err != nil {
|
||||||
continue
|
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
notebooks = append(notebooks, nb)
|
notebooks = append(notebooks, nb)
|
||||||
}
|
}
|
||||||
@@ -53,13 +55,40 @@ func handleListNotebooks(database *sql.DB) http.HandlerFunc {
|
|||||||
|
|
||||||
func handlePageSVG(database *sql.DB) http.HandlerFunc {
|
func handlePageSVG(database *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
strokes, pageSize, err := loadPageStrokes(r, database)
|
userID, ok := UserIDFromContext(r.Context())
|
||||||
if err != nil {
|
if !ok {
|
||||||
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
|
http.Error(w, `{"error":"unauthenticated"}`, http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
svg := render.RenderSVG(pageSize, strokes)
|
notebookID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"invalid id"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pageNum, err := strconv.Atoi(chi.URLParam(r, "num"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"invalid page number"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
internalID, err := verifyNotebookOwnership(database, notebookID, userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
strokes, pageSize, err := loadPageStrokesByNotebookID(database, internalID, pageNum)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
svg, err := render.RenderSVG(pageSize, strokes)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "image/svg+xml")
|
w.Header().Set("Content-Type", "image/svg+xml")
|
||||||
_, _ = w.Write([]byte(svg))
|
_, _ = w.Write([]byte(svg))
|
||||||
}
|
}
|
||||||
@@ -67,15 +96,38 @@ func handlePageSVG(database *sql.DB) http.HandlerFunc {
|
|||||||
|
|
||||||
func handlePageJPG(database *sql.DB) http.HandlerFunc {
|
func handlePageJPG(database *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
strokes, pageSize, err := loadPageStrokes(r, database)
|
userID, ok := UserIDFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, `{"error":"unauthenticated"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notebookID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
|
http.Error(w, `{"error":"invalid id"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pageNum, err := strconv.Atoi(chi.URLParam(r, "num"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"invalid page number"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
internalID, err := verifyNotebookOwnership(database, notebookID, userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
strokes, pageSize, err := loadPageStrokesByNotebookID(database, internalID, pageNum)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := render.RenderJPG(pageSize, strokes, 95)
|
data, err := render.RenderJPG(pageSize, strokes, 95)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"render error"}`, http.StatusInternalServerError)
|
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,19 +138,35 @@ func handlePageJPG(database *sql.DB) http.HandlerFunc {
|
|||||||
|
|
||||||
func handleNotebookPDF(database *sql.DB) http.HandlerFunc {
|
func handleNotebookPDF(database *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := UserIDFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, `{"error":"unauthenticated"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
notebookID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
notebookID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"invalid id"}`, http.StatusBadRequest)
|
http.Error(w, `{"error":"invalid id"}`, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
pages, pageSize, err := loadNotebookPages(database, notebookID)
|
internalID, err := verifyNotebookOwnership(database, notebookID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
|
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data := render.RenderPDF(pageSize, pages)
|
pages, pageSize, err := loadNotebookPages(database, internalID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := render.RenderPDF(pageSize, pages)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "application/pdf")
|
w.Header().Set("Content-Type", "application/pdf")
|
||||||
w.Header().Set("Content-Disposition", "attachment; filename=notebook.pdf")
|
w.Header().Set("Content-Disposition", "attachment; filename=notebook.pdf")
|
||||||
_, _ = w.Write(data)
|
_, _ = w.Write(data)
|
||||||
@@ -134,13 +202,17 @@ func handleSharePageSVG(database *sql.DB) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
strokes, pageSize, err := loadPageStrokesByNotebook(database, notebookID, pageNum)
|
strokes, pageSize, err := loadPageStrokesByNotebookID(database, notebookID, pageNum)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
|
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
svg := render.RenderSVG(pageSize, strokes)
|
svg, err := render.RenderSVG(pageSize, strokes)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "image/svg+xml")
|
w.Header().Set("Content-Type", "image/svg+xml")
|
||||||
_, _ = w.Write([]byte(svg))
|
_, _ = w.Write([]byte(svg))
|
||||||
}
|
}
|
||||||
@@ -161,15 +233,15 @@ func handleSharePageJPG(database *sql.DB) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
strokes, pageSize, err := loadPageStrokesByNotebook(database, notebookID, pageNum)
|
strokes, pageSize, err := loadPageStrokesByNotebookID(database, notebookID, pageNum)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
|
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := render.RenderJPG(pageSize, strokes, 95)
|
data, err := render.RenderJPG(pageSize, strokes, 95)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"render error"}`, http.StatusInternalServerError)
|
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,11 +261,15 @@ func handleSharePDF(database *sql.DB) http.HandlerFunc {
|
|||||||
|
|
||||||
pages, pageSize, err := loadNotebookPages(database, notebookID)
|
pages, pageSize, err := loadNotebookPages(database, notebookID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
|
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data := render.RenderPDF(pageSize, pages)
|
data, err := render.RenderPDF(pageSize, pages)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "application/pdf")
|
w.Header().Set("Content-Type", "application/pdf")
|
||||||
w.Header().Set("Content-Disposition", "attachment; filename=notebook.pdf")
|
w.Header().Set("Content-Disposition", "attachment; filename=notebook.pdf")
|
||||||
_, _ = w.Write(data)
|
_, _ = w.Write(data)
|
||||||
@@ -202,19 +278,22 @@ func handleSharePDF(database *sql.DB) http.HandlerFunc {
|
|||||||
|
|
||||||
// --- helpers ---
|
// --- helpers ---
|
||||||
|
|
||||||
func loadPageStrokes(r *http.Request, database *sql.DB) ([]render.Stroke, string, error) {
|
// verifyNotebookOwnership checks that notebookID belongs to userID and returns
|
||||||
notebookID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
// the internal (server-side) notebook ID.
|
||||||
|
func verifyNotebookOwnership(database *sql.DB, notebookID int64, userID int64) (int64, error) {
|
||||||
|
var internalID int64
|
||||||
|
err := database.QueryRow(
|
||||||
|
"SELECT id FROM notebooks WHERE id = ? AND user_id = ?", notebookID, userID,
|
||||||
|
).Scan(&internalID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return 0, fmt.Errorf("not found")
|
||||||
}
|
}
|
||||||
pageNum, err := strconv.Atoi(chi.URLParam(r, "num"))
|
return internalID, nil
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
return loadPageStrokesByNotebook(database, notebookID, pageNum)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadPageStrokesByNotebook(database *sql.DB, notebookID int64, pageNum int) ([]render.Stroke, string, error) {
|
// loadPageStrokesByNotebookID loads strokes for a page by internal notebook ID.
|
||||||
|
// This is used by both authenticated and share-link endpoints.
|
||||||
|
func loadPageStrokesByNotebookID(database *sql.DB, notebookID int64, pageNum int) ([]render.Stroke, string, error) {
|
||||||
var pageSize string
|
var pageSize string
|
||||||
err := database.QueryRow("SELECT page_size FROM notebooks WHERE id = ?", notebookID).Scan(&pageSize)
|
err := database.QueryRow("SELECT page_size FROM notebooks WHERE id = ?", notebookID).Scan(&pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -243,7 +322,7 @@ func loadPageStrokesByNotebook(database *sql.DB, notebookID int64, pageNum int)
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var s render.Stroke
|
var s render.Stroke
|
||||||
if err := rows.Scan(&s.PenSize, &s.Color, &s.Style, &s.PointData, &s.StrokeOrder); err != nil {
|
if err := rows.Scan(&s.PenSize, &s.Color, &s.Style, &s.PointData, &s.StrokeOrder); err != nil {
|
||||||
continue
|
return nil, "", fmt.Errorf("scan stroke: %w", err)
|
||||||
}
|
}
|
||||||
strokes = append(strokes, s)
|
strokes = append(strokes, s)
|
||||||
}
|
}
|
||||||
@@ -271,7 +350,7 @@ func loadNotebookPages(database *sql.DB, notebookID int64) ([]render.Page, strin
|
|||||||
var pageID int64
|
var pageID int64
|
||||||
var pageNum int
|
var pageNum int
|
||||||
if err := rows.Scan(&pageID, &pageNum); err != nil {
|
if err := rows.Scan(&pageID, &pageNum); err != nil {
|
||||||
continue
|
return nil, "", fmt.Errorf("scan page: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
strokeRows, err := database.Query(
|
strokeRows, err := database.Query(
|
||||||
@@ -279,14 +358,15 @@ func loadNotebookPages(database *sql.DB, notebookID int64) ([]render.Page, strin
|
|||||||
pageID,
|
pageID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
return nil, "", fmt.Errorf("query strokes: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var strokes []render.Stroke
|
var strokes []render.Stroke
|
||||||
for strokeRows.Next() {
|
for strokeRows.Next() {
|
||||||
var s render.Stroke
|
var s render.Stroke
|
||||||
if err := strokeRows.Scan(&s.PenSize, &s.Color, &s.Style, &s.PointData, &s.StrokeOrder); err != nil {
|
if err := strokeRows.Scan(&s.PenSize, &s.Color, &s.Style, &s.PointData, &s.StrokeOrder); err != nil {
|
||||||
continue
|
_ = strokeRows.Close()
|
||||||
|
return nil, "", fmt.Errorf("scan stroke: %w", err)
|
||||||
}
|
}
|
||||||
strokes = append(strokes, s)
|
strokes = append(strokes, s)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,13 +18,16 @@ type Config struct {
|
|||||||
BaseURL string
|
BaseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Start(cfg Config) error {
|
// Start creates and starts the REST API server. It returns the *http.Server
|
||||||
|
// so the caller can manage graceful shutdown. The server runs in a background
|
||||||
|
// goroutine.
|
||||||
|
func Start(cfg Config) (*http.Server, error) {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
RegisterRoutes(r, cfg.DB, cfg.BaseURL)
|
RegisterRoutes(r, cfg.DB, cfg.BaseURL)
|
||||||
|
|
||||||
tlsCert, err := tls.LoadX509KeyPair(cfg.TLSCert, cfg.TLSKey)
|
tlsCert, err := tls.LoadX509KeyPair(cfg.TLSCert, cfg.TLSKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load TLS cert: %w", err)
|
return nil, fmt.Errorf("load TLS cert: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
@@ -40,5 +43,7 @@ func Start(cfg Config) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("REST API listening on %s\n", cfg.Addr)
|
fmt.Printf("REST API listening on %s\n", cfg.Addr)
|
||||||
return srv.ListenAndServeTLS("", "")
|
go func() { _ = srv.ListenAndServeTLS("", "") }()
|
||||||
|
|
||||||
|
return srv, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,8 @@ func (ws *WebServer) handleNotebooks(w http.ResponseWriter, r *http.Request) {
|
|||||||
var nb notebook
|
var nb notebook
|
||||||
var syncedAt int64
|
var syncedAt int64
|
||||||
if err := rows.Scan(&nb.ID, &nb.Title, &nb.PageSize, &syncedAt, &nb.PageCount); err != nil {
|
if err := rows.Scan(&nb.ID, &nb.Title, &nb.PageSize, &syncedAt, &nb.PageCount); err != nil {
|
||||||
continue
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
nb.SyncedAt = time.UnixMilli(syncedAt).Format("2006-01-02 15:04")
|
nb.SyncedAt = time.UnixMilli(syncedAt).Format("2006-01-02 15:04")
|
||||||
notebooks = append(notebooks, nb)
|
notebooks = append(notebooks, nb)
|
||||||
@@ -118,7 +119,8 @@ func (ws *WebServer) handleNotebook(w http.ResponseWriter, r *http.Request) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var num int
|
var num int
|
||||||
if err := rows.Scan(&num); err != nil {
|
if err := rows.Scan(&num); err != nil {
|
||||||
continue
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
pages = append(pages, pageInfo{
|
pages = append(pages, pageInfo{
|
||||||
Number: num,
|
Number: num,
|
||||||
@@ -182,7 +184,8 @@ func (ws *WebServer) handleShareNotebook(w http.ResponseWriter, r *http.Request)
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var num int
|
var num int
|
||||||
if err := rows.Scan(&num); err != nil {
|
if err := rows.Scan(&num); err != nil {
|
||||||
continue
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
pages = append(pages, pageInfo{
|
pages = append(pages, pageInfo{
|
||||||
Number: num,
|
Number: num,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package webserver
|
package webserver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/tls"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
@@ -16,6 +17,8 @@ type Config struct {
|
|||||||
Addr string
|
Addr string
|
||||||
DB *sql.DB
|
DB *sql.DB
|
||||||
BaseURL string
|
BaseURL string
|
||||||
|
TLSCert string
|
||||||
|
TLSKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebServer struct {
|
type WebServer struct {
|
||||||
@@ -24,15 +27,15 @@ type WebServer struct {
|
|||||||
tmpl *template.Template
|
tmpl *template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
func Start(cfg Config) error {
|
func Start(cfg Config) (*http.Server, error) {
|
||||||
templateFS, err := fs.Sub(web.Content, "templates")
|
templateFS, err := fs.Sub(web.Content, "templates")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("template fs: %w", err)
|
return nil, fmt.Errorf("template fs: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpl, err := template.ParseFS(templateFS, "*.html")
|
tmpl, err := template.ParseFS(templateFS, "*.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("parse templates: %w", err)
|
return nil, fmt.Errorf("parse templates: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ws := &WebServer{
|
ws := &WebServer{
|
||||||
@@ -73,6 +76,21 @@ func Start(cfg Config) error {
|
|||||||
IdleTimeout: 120 * time.Second,
|
IdleTimeout: 120 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Web UI listening on %s\n", cfg.Addr)
|
if cfg.TLSCert != "" && cfg.TLSKey != "" {
|
||||||
return srv.ListenAndServe()
|
tlsCert, err := tls.LoadX509KeyPair(cfg.TLSCert, cfg.TLSKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load TLS cert: %w", err)
|
||||||
|
}
|
||||||
|
srv.TLSConfig = &tls.Config{
|
||||||
|
Certificates: []tls.Certificate{tlsCert},
|
||||||
|
MinVersion: tls.VersionTLS13,
|
||||||
|
}
|
||||||
|
fmt.Printf("Web UI listening on %s (TLS)\n", cfg.Addr)
|
||||||
|
go func() { _ = srv.ListenAndServeTLS("", "") }()
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Web UI listening on %s\n", cfg.Addr)
|
||||||
|
go func() { _ = srv.ListenAndServe() }()
|
||||||
|
}
|
||||||
|
|
||||||
|
return srv, nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user