Skip to content

Commit 289f8cd

Browse files
committed
fix(embed): add middleware to authorize embed create document (with hard rate limit)
1 parent 9ad267a commit 289f8cd

File tree

7 files changed

+311
-16
lines changed

7 files changed

+311
-16
lines changed

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,20 +181,24 @@ User authenticates via OAuth2 and signs with one click.
181181

182182
**iFrame**:
183183
```html
184-
<iframe src="https://your-domain.com/?doc=policy_2025"
184+
<iframe src="https://your-domain.com/embed?doc=policy_2025"
185185
width="600" height="200" frameborder="0"></iframe>
186186
```
187187

188188
**oEmbed** (Notion, Outline, Confluence):
189189
```
190-
Just paste the URL - automatic embed via oEmbed discovery
190+
Paste the embed URL: https://your-domain.com/embed?doc=policy_2025
191+
Automatic embed via oEmbed discovery
191192
```
192193

193194
**Open Graph** (Slack, Teams):
194195
```
196+
Paste direct URL: https://your-domain.com/?doc=policy_2025
195197
URL unfurls automatically with signature count
196198
```
197199

200+
> **Important**: Use `/embed?doc=...` for iframe integrations (Notion, Outline) and `/?doc=...` for direct links (emails, Slack).
201+
198202
See [docs/en/features/embedding.md](docs/en/features/embedding.md) for details.
199203

200204
---

README_FR.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,20 +181,24 @@ L'utilisateur s'authentifie via OAuth2 et signe en un clic.
181181

182182
**iFrame** :
183183
```html
184-
<iframe src="https://votre-domaine.com/?doc=politique_2025"
184+
<iframe src="https://votre-domaine.com/embed?doc=politique_2025"
185185
width="600" height="200" frameborder="0"></iframe>
186186
```
187187

188188
**oEmbed** (Notion, Outline, Confluence) :
189189
```
190-
Collez simplement l'URL - embed automatique via oEmbed discovery
190+
Collez l'URL embed : https://votre-domaine.com/embed?doc=politique_2025
191+
Embed automatique via oEmbed discovery
191192
```
192193

193194
**Open Graph** (Slack, Teams) :
194195
```
196+
Collez l'URL directe : https://votre-domaine.com/?doc=politique_2025
195197
L'URL se déploie automatiquement avec le nombre de signatures
196198
```
197199

200+
> **Important** : Utilisez `/embed?doc=...` pour les intégrations iframe (Notion, Outline) et `/?doc=...` pour les liens directs (emails, Slack).
201+
198202
Voir [docs/fr/features/embedding.md](docs/fr/features/embedding.md) pour les détails.
199203

200204
---

backend/internal/domain/models/document.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,18 @@ func (d *Document) GetExpectedChecksumLength() int {
4444
return 0
4545
}
4646
}
47+
48+
// GetDocID returns the document ID
49+
func (d *Document) GetDocID() string {
50+
return d.DocID
51+
}
52+
53+
// GetTitle returns the document title
54+
func (d *Document) GetTitle() string {
55+
return d.Title
56+
}
57+
58+
// GetURL returns the document URL
59+
func (d *Document) GetURL() string {
60+
return d.URL
61+
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
package web
3+
4+
import (
5+
"context"
6+
"net/http"
7+
"strings"
8+
"sync"
9+
"time"
10+
11+
"github.com/btouchard/ackify-ce/backend/internal/domain/models"
12+
"github.com/btouchard/ackify-ce/backend/pkg/logger"
13+
)
14+
15+
type docService interface {
16+
FindOrCreateDocument(ctx context.Context, ref string) (*models.Document, bool, error)
17+
}
18+
19+
// webhookPublisher defines minimal publish capability
20+
type webhookPublisher interface {
21+
Publish(ctx context.Context, eventType string, payload map[string]interface{}) error
22+
}
23+
24+
// EmbedDocumentMiddleware creates documents on /embed access with strict rate limiting
25+
// This ensures documents exist before the SPA renders, without requiring authentication
26+
// The docServiceFn should be a function that calls FindOrCreateDocument
27+
func EmbedDocumentMiddleware(
28+
docService docService,
29+
publisher webhookPublisher,
30+
) func(http.Handler) http.Handler {
31+
rateLimiter := newEmbedRateLimiter(2, time.Minute)
32+
33+
return func(next http.Handler) http.Handler {
34+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
35+
// Only intercept /embed path
36+
if !strings.HasPrefix(r.URL.Path, "/embed") {
37+
next.ServeHTTP(w, r)
38+
return
39+
}
40+
41+
// Check rate limit
42+
ip := getClientIP(r)
43+
if !rateLimiter.Allow(ip) {
44+
logger.Logger.Warn("Embed rate limit exceeded",
45+
"ip", ip,
46+
"path", r.URL.Path)
47+
// Let the request continue to SPA - frontend will handle the error display
48+
// The frontend can check for rate limit errors via API calls
49+
next.ServeHTTP(w, r)
50+
return
51+
}
52+
53+
// Get doc ID from query parameter
54+
docID := r.URL.Query().Get("doc")
55+
if docID == "" {
56+
// No doc parameter, let SPA handle it
57+
next.ServeHTTP(w, r)
58+
return
59+
}
60+
61+
// Try to create document if it doesn't exist
62+
ctx := r.Context()
63+
doc, isNew, err := docService.FindOrCreateDocument(ctx, docID)
64+
if err != nil {
65+
logger.Logger.Error("Failed to find/create document for embed",
66+
"doc_id", docID,
67+
"error", err.Error(),
68+
"ip", ip)
69+
// Continue to SPA anyway - it will handle the error
70+
next.ServeHTTP(w, r)
71+
return
72+
}
73+
74+
if isNew {
75+
logger.Logger.Info("Document auto-created via embed view",
76+
"doc_id", docID,
77+
"ip", ip)
78+
79+
// Publish webhook event for auto-created documents
80+
if publisher != nil {
81+
_ = publisher.Publish(ctx, "document.created", map[string]interface{}{
82+
"doc_id": doc.GetDocID(),
83+
"title": doc.GetTitle(),
84+
"url": doc.GetURL(),
85+
"source": "embed_view",
86+
})
87+
}
88+
}
89+
90+
// Continue to SPA
91+
next.ServeHTTP(w, r)
92+
})
93+
}
94+
}
95+
96+
// embedRateLimiter implements a simple IP-based rate limiter
97+
type embedRateLimiter struct {
98+
attempts *sync.Map
99+
limit int
100+
window time.Duration
101+
}
102+
103+
func newEmbedRateLimiter(limit int, window time.Duration) *embedRateLimiter {
104+
return &embedRateLimiter{
105+
attempts: &sync.Map{},
106+
limit: limit,
107+
window: window,
108+
}
109+
}
110+
111+
func (rl *embedRateLimiter) Allow(ip string) bool {
112+
now := time.Now()
113+
114+
// Check current attempts
115+
if val, ok := rl.attempts.Load(ip); ok {
116+
attempts := val.([]time.Time)
117+
118+
// Filter out old attempts
119+
var valid []time.Time
120+
for _, t := range attempts {
121+
if now.Sub(t) < rl.window {
122+
valid = append(valid, t)
123+
}
124+
}
125+
126+
if len(valid) >= rl.limit {
127+
return false
128+
}
129+
130+
valid = append(valid, now)
131+
rl.attempts.Store(ip, valid)
132+
} else {
133+
rl.attempts.Store(ip, []time.Time{now})
134+
}
135+
136+
return true
137+
}
138+
139+
func getClientIP(r *http.Request) string {
140+
// Try X-Forwarded-For first (for proxies)
141+
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
142+
ips := strings.Split(forwarded, ",")
143+
return strings.TrimSpace(ips[0])
144+
}
145+
146+
// Try X-Real-IP
147+
if realIP := r.Header.Get("X-Real-IP"); realIP != "" {
148+
return realIP
149+
}
150+
151+
// Fallback to RemoteAddr
152+
ip := r.RemoteAddr
153+
if idx := strings.LastIndex(ip, ":"); idx != -1 {
154+
ip = ip[:idx]
155+
}
156+
return ip
157+
}

backend/pkg/web/server.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,13 @@ func NewServer(ctx context.Context, cfg *config.Config, frontend embed.FS, versi
126126

127127
router.Use(i18n.Middleware(i18nService))
128128

129+
// Embed middleware: intercepts /embed to ensure document exists (with strict rate limit)
130+
// This runs BEFORE the SPA is served, allowing Notion/Outline embeds to work
131+
router.Use(EmbedDocumentMiddleware(
132+
documentService,
133+
webhookPublisher,
134+
))
135+
129136
apiConfig := api.RouterConfig{
130137
AuthService: authService,
131138
SignatureService: signatureService,

docs/en/features/embedding.md

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,47 @@
22

33
Integrate Ackify into your tools (Notion, Outline, Google Docs, etc.).
44

5+
## URL Formats: When to Use What
6+
7+
Ackify provides two URL formats depending on your use case:
8+
9+
### `/?doc=<id>` - Full Page Experience
10+
11+
**Use for**:
12+
- Direct links in emails, chat messages
13+
- Standalone signature pages
14+
- When you want full navigation and context
15+
16+
**Behavior**:
17+
- Full page with header, navigation, and footer
18+
- Optimized for direct user access
19+
- Complete branding and organization context
20+
21+
**Example**:
22+
```
23+
https://sign.company.com/?doc=policy_2025
24+
```
25+
26+
### `/embed?doc=<id>` - Embed-Optimized View
27+
28+
**Use for**:
29+
- iFrame embeds in Notion, Outline, Confluence
30+
- Widget integrations in third-party platforms
31+
- When you want a clean, minimal interface
32+
33+
**Behavior**:
34+
- Minimal interface without navigation
35+
- Optimized for small iframe containers
36+
- Focuses only on signature status and action button
37+
- No automatic redirects
38+
39+
**Example**:
40+
```
41+
https://sign.company.com/embed?doc=policy_2025
42+
```
43+
44+
> **Important**: For embedding in Notion/Outline, always use `/embed?doc=...` to avoid unwanted redirections and get the optimal embed experience.
45+
546
## Integration Methods
647

748
### 1. Direct Link
@@ -27,7 +68,7 @@ https://sign.company.com/?doc=policy_2025
2768
To integrate in a web page:
2869

2970
```html
30-
<iframe src="https://sign.company.com/?doc=policy_2025"
71+
<iframe src="https://sign.company.com/embed?doc=policy_2025"
3172
width="600"
3273
height="200"
3374
frameborder="0"
@@ -113,26 +154,37 @@ Ackify automatically generates meta tags for previews:
113154

114155
### Notion
115156

157+
**Method 1: Auto-embed (Recommended)**
158+
116159
1. Paste URL in a Notion page:
117160
```
118-
https://sign.company.com/?doc=policy_2025
161+
https://sign.company.com/embed?doc=policy_2025
119162
```
120163

121164
2. Notion auto-detects oEmbed
122165
3. Widget appears with signature button
123166

124-
**Alternative**: Create manual embed
125-
- `/embed` → Paste URL
167+
**Method 2: Manual embed**
168+
169+
1. Type `/embed` in Notion
170+
2. Paste the embed URL:
171+
```
172+
https://sign.company.com/embed?doc=policy_2025
173+
```
174+
175+
> **Tip**: Use `/embed?doc=...` instead of `/?doc=...` to get the optimal embed view without navigation elements.
126176
127177
### Outline
128178

129179
1. In an Outline document, paste:
130180
```
131-
https://sign.company.com/?doc=policy_2025
181+
https://sign.company.com/embed?doc=policy_2025
132182
```
133183

134184
2. Outline automatically loads the widget
135185

186+
> **Note**: Using `/embed?doc=...` ensures you get the clean widget view without redirects.
187+
136188
### Google Docs
137189

138190
Google Docs doesn't support iframes directly, but:
@@ -160,9 +212,11 @@ See [docs/integrations/google-doc/](../integrations/google-doc/) for more detail
160212
2. Insert "oEmbed" or "HTML Embed" macro
161213
3. Paste:
162214
```
163-
https://sign.company.com/?doc=policy_2025
215+
https://sign.company.com/embed?doc=policy_2025
164216
```
165217

218+
> **Note**: Use `/embed?doc=...` for the best iframe experience.
219+
166220
### Slack
167221

168222
**Link Unfurling**:

0 commit comments

Comments
 (0)