v0.29.0 21b3150

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

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