v0.28.0 3cb3770

handle_permission.go RawHistoryFolder

1// Package usecase contains application use cases that orchestrate domain logic.
2package usecase
3
4import (
5	"context"
6	"errors"
7	"sync"
8	"time"
9
10	"github.com/bnema/dumber/internal/application/port"
11	"github.com/bnema/dumber/internal/domain/entity"
12	"github.com/bnema/dumber/internal/logging"
13	"github.com/rs/zerolog"
14)
15
16// PermissionCallback provides allow/deny functions for the permission request.
17type PermissionCallback struct {
18	Allow func()
19	Deny  func()
20}
21
22// HandlePermissionUseCase handles media permission requests from WebKit.
23// It implements the permission strategy defined in the architecture:
24// - Display capture: auto-allow (XDG portal handles UI)
25// - Device enumeration: auto-allow (low risk)
26// - Mic/Camera: check stored → dialog → persist if "Always"
27type HandlePermissionUseCase struct {
28	permRepo          port.PermissionRepository
29	dialog            port.PermissionDialogPresenter
30	loggerFromContext port.LoggerFromContext
31	dialogMu          sync.RWMutex
32}
33
34// NewHandlePermissionUseCase creates a new permission handling use case.
35func NewHandlePermissionUseCase(
36	permRepo port.PermissionRepository,
37	dialog port.PermissionDialogPresenter,
38	loggerFromContext port.LoggerFromContext,
39) *HandlePermissionUseCase {
40	return &HandlePermissionUseCase{
41		permRepo:          permRepo,
42		dialog:            dialog,
43		loggerFromContext: loggerFromContext,
44	}
45}
46
47func (uc *HandlePermissionUseCase) logger(ctx context.Context) *zerolog.Logger {
48	if ctx == nil {
49		ctx = context.Background()
50	}
51	if uc == nil || uc.loggerFromContext == nil {
52		return zerolog.Ctx(ctx)
53	}
54	log := uc.loggerFromContext(ctx)
55	if log == nil {
56		return zerolog.Ctx(ctx)
57	}
58	return log
59}
60
61const maxPermissionLogURLLen = 96
62
63func isWebsiteDataAccessPermission(permTypes []entity.PermissionType) bool {
64	for _, permType := range permTypes {
65		if permType == entity.PermissionTypeWebsiteDataAccess {
66			return true
67		}
68	}
69	return false
70}
71
72func permissionLoggerWithMetadata(log *zerolog.Logger, metadata entity.PermissionMetadata) *zerolog.Logger {
73	if log == nil {
74		nop := zerolog.Nop()
75		return &nop
76	}
77	ctx := log.With()
78	if requesting := metadata[entity.PermissionMetadataKeyRequestingDomain]; requesting != "" {
79		ctx = ctx.Str("requesting_domain", logging.TruncateURL(requesting, maxPermissionLogURLLen))
80	}
81	if current := metadata[entity.PermissionMetadataKeyCurrentDomain]; current != "" {
82		ctx = ctx.Str("current_domain", logging.TruncateURL(current, maxPermissionLogURLLen))
83	}
84	enriched := ctx.Logger()
85	return &enriched
86}
87
88// SetDialogPresenter sets the dialog presenter. This can be called after
89// initialization when the UI window is available.
90func (uc *HandlePermissionUseCase) SetDialogPresenter(dialog port.PermissionDialogPresenter) {
91	uc.dialogMu.Lock()
92	defer uc.dialogMu.Unlock()
93	uc.dialog = dialog
94}
95
96// getDialog safely returns the current dialog presenter.
97func (uc *HandlePermissionUseCase) getDialog() port.PermissionDialogPresenter {
98	uc.dialogMu.RLock()
99	defer uc.dialogMu.RUnlock()
100	return uc.dialog
101}
102
103// HandlePermissionRequest processes a permission request from WebKit.
104// This is the main entry point for the permission use case.
105//
106// Parameters:
107//   - ctx: context for cancellation and logging
108//   - origin: the website origin (URI) requesting permission
109//   - permTypes: the types of permissions being requested
110//   - metadata: permission-type-specific context; for website_data_access both keys must be present:
111//     entity.PermissionMetadataKeyRequestingDomain — the origin requesting cross-site data access
112//     entity.PermissionMetadataKeyCurrentDomain    — the domain currently loaded in the WebView
113//   - callback: callbacks to allow or deny the request (must call one)
114func (uc *HandlePermissionUseCase) HandlePermissionRequest(
115	ctx context.Context,
116	origin string,
117	permTypes []entity.PermissionType,
118	metadata entity.PermissionMetadata,
119	callback PermissionCallback,
120) {
121	log := uc.logger(ctx).With().
122		Str("component", "permission").
123		Str("origin", origin).
124		Strs("types", entity.PermissionTypesToStrings(permTypes)).
125		Logger()
126
127	// Validate origin
128	if origin == "" {
129		log.Warn().Msg("permission request with empty origin, denying")
130		callback.Deny()
131		return
132	}
133
134	// Check if all types are auto-allow
135	// Also handle empty permTypes as a deny (shouldn't happen but defensive)
136	if len(permTypes) == 0 {
137		log.Warn().Msg("permission request with empty types, denying")
138		callback.Deny()
139		return
140	}
141
142	if uc.isAutoAllowOverrideDenied(ctx, origin, permTypes) {
143		log.Debug().Msg("manual override denied auto-allow permission request")
144		callback.Deny()
145		return
146	}
147
148	if isAutoAllowSet(permTypes) {
149		log.Debug().Msg("auto-allowing permission request")
150		callback.Allow()
151		return
152	}
153
154	// For mic/camera: check stored permissions first
155	decision := uc.checkStoredPermissions(ctx, origin, permTypes)
156	switch decision {
157	case entity.PermissionGranted:
158		if isWebsiteDataAccessPermission(permTypes) {
159			permissionLoggerWithMetadata(&log, metadata).Info().
160				Msg("using stored website data access permission: granted")
161		}
162		log.Debug().Msg("using stored permission: granted")
163		callback.Allow()
164		return
165	case entity.PermissionDenied:
166		if isWebsiteDataAccessPermission(permTypes) {
167			permissionLoggerWithMetadata(&log, metadata).Info().
168				Msg("using stored website data access permission: denied")
169		}
170		log.Debug().Msg("using stored permission: denied")
171		callback.Deny()
172		return
173	case entity.PermissionPrompt:
174		// Fall through to show dialog
175	}
176
177	// Show dialog for undecided permissions
178	uc.showPermissionDialog(ctx, origin, permTypes, metadata, callback)
179}
180
181// QueryPermissionState returns the current permission state for the W3C Permissions API.
182// This is used by websites to check if they already have permission before calling getUserMedia().
183func (uc *HandlePermissionUseCase) QueryPermissionState(
184	ctx context.Context,
185	origin string,
186	permType entity.PermissionType,
187) entity.PermissionDecision {
188	log := uc.logger(ctx).With().
189		Str("component", "permission").
190		Str("origin", origin).
191		Str("type", string(permType)).
192		Logger()
193
194	if origin == "" {
195		log.Warn().Msg("query: empty origin, returning prompt")
196		return entity.PermissionPrompt
197	}
198
199	// Check stored permission first (including manual overrides on auto-allow types).
200	record, err := uc.permRepo.Get(ctx, origin, permType)
201	if err != nil {
202		log.Warn().Err(err).Msg("query: failed to get stored permission, returning prompt")
203		return entity.PermissionPrompt
204	}
205
206	if record != nil {
207		log.Debug().Str("decision", string(record.Decision)).Msg("query: returning stored permission")
208		return record.Decision
209	}
210
211	if entity.IsAutoAllow(permType) {
212		log.Debug().Msg("query: no stored permission for auto-allow type, returning granted")
213		return entity.PermissionGranted
214	}
215
216	log.Debug().Msg("query: no stored permission, returning prompt")
217	return entity.PermissionPrompt
218}
219
220// isAutoAllowSet returns true if all permission types in the set are auto-allow.
221func isAutoAllowSet(permTypes []entity.PermissionType) bool {
222	for _, pt := range permTypes {
223		if !entity.IsAutoAllow(pt) {
224			return false
225		}
226	}
227	return true
228}
229
230// checkStoredPermissions checks if all permissions in the set have stored decisions.
231// Returns granted if all are granted, denied if any are denied, prompt otherwise.
232func (uc *HandlePermissionUseCase) checkStoredPermissions(
233	ctx context.Context,
234	origin string,
235	permTypes []entity.PermissionType,
236) entity.PermissionDecision {
237	log := uc.logger(ctx)
238
239	hasPrompt := false
240
241	for _, permType := range permTypes {
242		// Skip auto-allow types - they're handled before this function
243		if entity.IsAutoAllow(permType) {
244			continue
245		}
246
247		// Non-persistable types that aren't auto-allow should prompt
248		// This shouldn't happen in practice, but we handle it defensively
249		if !entity.CanPersist(permType) {
250			hasPrompt = true
251			continue
252		}
253
254		record, err := uc.permRepo.Get(ctx, origin, permType)
255		if err != nil {
256			log.Warn().
257				Err(err).
258				Str("perm_type", string(permType)).
259				Msg("failed to get stored permission")
260			// On error, treat as prompt (fall back to dialog)
261			hasPrompt = true
262			continue
263		}
264
265		if record == nil {
266			hasPrompt = true
267			continue
268		}
269
270		switch record.Decision {
271		case entity.PermissionDenied:
272			// If any permission is denied, deny the whole request (conservative)
273			return entity.PermissionDenied
274		case entity.PermissionPrompt:
275			hasPrompt = true
276		}
277	}
278
279	if hasPrompt {
280		return entity.PermissionPrompt
281	}
282
283	// All permissions are granted
284	return entity.PermissionGranted
285}
286
287// showPermissionDialog shows the permission dialog and handles the result.
288func (uc *HandlePermissionUseCase) showPermissionDialog(
289	ctx context.Context,
290	origin string,
291	permTypes []entity.PermissionType,
292	metadata entity.PermissionMetadata,
293	callback PermissionCallback,
294) {
295	log := uc.logger(ctx)
296
297	dialog := uc.getDialog()
298
299	// If no dialog presenter is set, deny the permission
300	if dialog == nil {
301		log.Warn().Str("origin", origin).Msg("no dialog presenter available, denying permission")
302		callback.Deny()
303		return
304	}
305
306	dialog.ShowPermissionDialog(ctx, origin, permTypes, metadata, func(result port.PermissionDialogResult) {
307		if result.Allowed {
308			if isWebsiteDataAccessPermission(permTypes) {
309				permissionLoggerWithMetadata(log, metadata).Info().
310					Bool("persistent", result.Persistent).
311					Msg("website data access permission allowed")
312			}
313			log.Debug().Bool("persistent", result.Persistent).Msg("user allowed permission")
314			callback.Allow()
315
316			// Persist if "Always Allow" was chosen
317			if result.Persistent {
318				uc.persistPermission(ctx, origin, permTypes, entity.PermissionGranted)
319			}
320		} else {
321			if isWebsiteDataAccessPermission(permTypes) {
322				permissionLoggerWithMetadata(log, metadata).Info().
323					Bool("persistent", result.Persistent).
324					Msg("website data access permission denied")
325			}
326			log.Debug().Msg("user denied permission")
327			callback.Deny()
328
329			// Persist denial if "Always Deny" was chosen (Persistent=true + Allowed=false)
330			if result.Persistent {
331				uc.persistPermission(ctx, origin, permTypes, entity.PermissionDenied)
332			}
333		}
334	})
335}
336
337// persistPermission saves the permission decision to the repository.
338func (uc *HandlePermissionUseCase) persistPermission(
339	ctx context.Context,
340	origin string,
341	permTypes []entity.PermissionType,
342	decision entity.PermissionDecision,
343) {
344	log := uc.logger(ctx)
345
346	for _, permType := range permTypes {
347		// Only persist allowed types
348		if !entity.CanPersist(permType) {
349			continue
350		}
351
352		record := &entity.PermissionRecord{
353			Origin:    origin,
354			Type:      permType,
355			Decision:  decision,
356			UpdatedAt: time.Now().Unix(),
357		}
358
359		if err := uc.permRepo.Set(ctx, record); err != nil {
360			log.Warn().
361				Err(err).
362				Str("perm_type", string(permType)).
363				Str("decision", string(decision)).
364				Msg("failed to persist permission")
365		}
366	}
367}
368
369// GetManualPermissionDecision returns the stored decision (if any) for origin/type.
370func (uc *HandlePermissionUseCase) GetManualPermissionDecision(
371	ctx context.Context,
372	origin string,
373	permType entity.PermissionType,
374) (*entity.PermissionRecord, error) {
375	if origin == "" {
376		return nil, errors.New("origin is required")
377	}
378	return uc.permRepo.Get(ctx, origin, permType)
379}
380
381// SetManualPermissionDecision stores an explicit decision for origin/type.
382// This is used by the UI permission indicator as the source of truth for lock state.
383func (uc *HandlePermissionUseCase) SetManualPermissionDecision(
384	ctx context.Context,
385	origin string,
386	permType entity.PermissionType,
387	decision entity.PermissionDecision,
388) error {
389	if origin == "" {
390		return errors.New("origin is required")
391	}
392	if !entity.CanPersist(permType) {
393		return errors.New("permission type not persistable")
394	}
395	if decision == entity.PermissionPrompt {
396		return uc.permRepo.Delete(ctx, origin, permType)
397	}
398
399	return uc.permRepo.Set(ctx, &entity.PermissionRecord{
400		Origin:    origin,
401		Type:      permType,
402		Decision:  decision,
403		UpdatedAt: time.Now().Unix(),
404	})
405}
406
407// ResetManualPermissionDecision clears any stored decision for origin/type.
408func (uc *HandlePermissionUseCase) ResetManualPermissionDecision(
409	ctx context.Context,
410	origin string,
411	permType entity.PermissionType,
412) error {
413	if origin == "" {
414		return errors.New("origin is required")
415	}
416	return uc.permRepo.Delete(ctx, origin, permType)
417}
418
419func (uc *HandlePermissionUseCase) isAutoAllowOverrideDenied(
420	ctx context.Context,
421	origin string,
422	permTypes []entity.PermissionType,
423) bool {
424	for _, permType := range permTypes {
425		if !entity.IsAutoAllow(permType) {
426			continue
427		}
428
429		record, err := uc.permRepo.Get(ctx, origin, permType)
430		if err != nil || record == nil {
431			continue
432		}
433
434		if record.Decision == entity.PermissionDenied {
435			return true
436		}
437	}
438
439	return false
440}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close