main 5041353

handle_permission.go RawHistoryFolder

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