v0.31.0 bf0756b

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) {
137		log.Debug().Msg("manual override denied auto-allow permission request")
138		callback.Deny()
139		return
140	}
141
142	if isAutoAllowSet(permTypes) {
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)
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// This is used by websites to check if they already have permission before calling getUserMedia().
177func (uc *HandlePermissionUseCase) QueryPermissionState(
178	ctx context.Context,
179	origin string,
180	permType entity.PermissionType,
181) entity.PermissionDecision {
182	log := uc.logger(ctx).With().
183		Str("component", "permission").
184		Str("origin", origin).
185		Str("type", string(permType)).
186		Logger()
187
188	if origin == "" {
189		log.Warn().Msg("query: empty origin, returning prompt")
190		return entity.PermissionPrompt
191	}
192
193	// Check stored permission first (including manual overrides on auto-allow types).
194	record, err := uc.permRepo.Get(ctx, origin, permType)
195	if err != nil {
196		log.Warn().Err(err).Msg("query: failed to get stored permission, returning prompt")
197		return entity.PermissionPrompt
198	}
199
200	if record != nil {
201		log.Debug().Str("decision", string(record.Decision)).Msg("query: returning stored permission")
202		return record.Decision
203	}
204
205	if entity.IsAutoAllow(permType) {
206		log.Debug().Msg("query: no stored permission for auto-allow type, returning granted")
207		return entity.PermissionGranted
208	}
209
210	log.Debug().Msg("query: no stored permission, returning prompt")
211	return entity.PermissionPrompt
212}
213
214// isAutoAllowSet returns true if all permission types in the set are auto-allow.
215func isAutoAllowSet(permTypes []entity.PermissionType) bool {
216	for _, pt := range permTypes {
217		if !entity.IsAutoAllow(pt) {
218			return false
219		}
220	}
221	return true
222}
223
224// checkStoredPermissions checks if all permissions in the set have stored decisions.
225// Returns granted if all are granted, denied if any are denied, prompt otherwise.
226func (uc *HandlePermissionUseCase) checkStoredPermissions(
227	ctx context.Context,
228	origin string,
229	permTypes []entity.PermissionType,
230) entity.PermissionDecision {
231	log := uc.logger(ctx)
232
233	hasPrompt := false
234
235	for _, permType := range permTypes {
236		// Skip auto-allow types - they're handled before this function
237		if entity.IsAutoAllow(permType) {
238			continue
239		}
240
241		// Non-persistable types that aren't auto-allow should prompt
242		// This shouldn't happen in practice, but we handle it defensively
243		if !entity.CanPersist(permType) {
244			hasPrompt = true
245			continue
246		}
247
248		record, err := uc.permRepo.Get(ctx, origin, permType)
249		if err != nil {
250			log.Warn().
251				Err(err).
252				Str("perm_type", string(permType)).
253				Msg("failed to get stored permission")
254			// On error, treat as prompt (fall back to dialog)
255			hasPrompt = true
256			continue
257		}
258
259		if record == nil {
260			hasPrompt = true
261			continue
262		}
263
264		switch record.Decision {
265		case entity.PermissionDenied:
266			// If any permission is denied, deny the whole request (conservative)
267			return entity.PermissionDenied
268		case entity.PermissionPrompt:
269			hasPrompt = true
270		}
271	}
272
273	if hasPrompt {
274		return entity.PermissionPrompt
275	}
276
277	// All permissions are granted
278	return entity.PermissionGranted
279}
280
281// showPermissionDialog shows the permission dialog and handles the result.
282func (uc *HandlePermissionUseCase) showPermissionDialog(
283	ctx context.Context,
284	origin string,
285	permTypes []entity.PermissionType,
286	metadata entity.PermissionMetadata,
287	callback PermissionCallback,
288) {
289	log := uc.logger(ctx)
290
291	dialog := uc.getDialog()
292
293	// If no dialog presenter is set, deny the permission
294	if dialog == nil {
295		log.Warn().Str("origin", origin).Msg("no dialog presenter available, denying permission")
296		callback.Deny()
297		return
298	}
299
300	dialog.ShowPermissionDialog(ctx, origin, permTypes, metadata, func(result port.PermissionDialogResult) {
301		if result.Allowed {
302			if isWebsiteDataAccessPermission(permTypes) {
303				permissionLoggerWithMetadata(log, metadata).Info().
304					Bool("persistent", result.Persistent).
305					Msg("website data access permission allowed")
306			}
307			log.Debug().Bool("persistent", result.Persistent).Msg("user allowed permission")
308			callback.Allow()
309
310			// Persist if "Always Allow" was chosen
311			if result.Persistent {
312				uc.persistPermission(ctx, origin, permTypes, entity.PermissionGranted)
313			}
314		} else {
315			if isWebsiteDataAccessPermission(permTypes) {
316				permissionLoggerWithMetadata(log, metadata).Info().
317					Bool("persistent", result.Persistent).
318					Msg("website data access permission denied")
319			}
320			log.Debug().Msg("user denied permission")
321			callback.Deny()
322
323			// Persist denial if "Always Deny" was chosen (Persistent=true + Allowed=false)
324			if result.Persistent {
325				uc.persistPermission(ctx, origin, permTypes, entity.PermissionDenied)
326			}
327		}
328	})
329}
330
331// persistPermission saves the permission decision to the repository.
332func (uc *HandlePermissionUseCase) persistPermission(
333	ctx context.Context,
334	origin string,
335	permTypes []entity.PermissionType,
336	decision entity.PermissionDecision,
337) {
338	log := uc.logger(ctx)
339
340	for _, permType := range permTypes {
341		// Only persist allowed types
342		if !entity.CanPersist(permType) {
343			continue
344		}
345
346		record := &entity.PermissionRecord{
347			Origin:    origin,
348			Type:      permType,
349			Decision:  decision,
350			UpdatedAt: time.Now().Unix(),
351		}
352
353		if err := uc.permRepo.Set(ctx, record); err != nil {
354			log.Warn().
355				Err(err).
356				Str("perm_type", string(permType)).
357				Str("decision", string(decision)).
358				Msg("failed to persist permission")
359		}
360	}
361}
362
363// GetManualPermissionDecision returns the stored decision (if any) for origin/type.
364func (uc *HandlePermissionUseCase) GetManualPermissionDecision(
365	ctx context.Context,
366	origin string,
367	permType entity.PermissionType,
368) (*entity.PermissionRecord, error) {
369	if origin == "" {
370		return nil, errors.New("origin is required")
371	}
372	return uc.permRepo.Get(ctx, origin, permType)
373}
374
375// SetManualPermissionDecision stores an explicit decision for origin/type.
376// This is used by the UI permission indicator as the source of truth for lock state.
377func (uc *HandlePermissionUseCase) SetManualPermissionDecision(
378	ctx context.Context,
379	origin string,
380	permType entity.PermissionType,
381	decision entity.PermissionDecision,
382) error {
383	if origin == "" {
384		return errors.New("origin is required")
385	}
386	if !entity.CanPersist(permType) {
387		return errors.New("permission type not persistable")
388	}
389	if decision == entity.PermissionPrompt {
390		return uc.permRepo.Delete(ctx, origin, permType)
391	}
392
393	return uc.permRepo.Set(ctx, &entity.PermissionRecord{
394		Origin:    origin,
395		Type:      permType,
396		Decision:  decision,
397		UpdatedAt: time.Now().Unix(),
398	})
399}
400
401// ResetManualPermissionDecision clears any stored decision for origin/type.
402func (uc *HandlePermissionUseCase) ResetManualPermissionDecision(
403	ctx context.Context,
404	origin string,
405	permType entity.PermissionType,
406) error {
407	if origin == "" {
408		return errors.New("origin is required")
409	}
410	return uc.permRepo.Delete(ctx, origin, permType)
411}
412
413func (uc *HandlePermissionUseCase) isAutoAllowOverrideDenied(
414	ctx context.Context,
415	origin string,
416	permTypes []entity.PermissionType,
417) bool {
418	for _, permType := range permTypes {
419		if !entity.IsAutoAllow(permType) {
420			continue
421		}
422
423		record, err := uc.permRepo.Get(ctx, origin, permType)
424		if err != nil || record == nil {
425			continue
426		}
427
428		if record.Decision == entity.PermissionDenied {
429			return true
430		}
431	}
432
433	return false
434}

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