Kareadita/Kavita · error · KavitaException
denied
Error message
denied
What it means
Thrown by AnnotationService.UpdateAnnotation when the annotation for dto.Id is not found OR its AppUserId does not match the requesting userId — an ownership/authorization guard that intentionally collapses 'missing' and 'not-yours' into one 'denied'. NOTE: the throw is inside the method's try (line 115) and is caught by catch (Exception) at line 137, which only logs and then falls through to throw 'generic-error' at line 142. So the caller never receives 'denied'; it always surfaces as 'generic-error'.
Source
Thrown at Kavita.Services/AnnotationService.cs:118
logger.LogError(ex, "There was an exception when creating an annotation on {ChapterId} - Page {Page}", dto.ChapterId, dto.PageNumber);
throw new KavitaException("annotation-failed-create");
}
}
/// <summary>
/// Update the modifiable fields (Spoiler, highlight slot, and comment) for an annotation
/// </summary>
/// <param name="userId"></param>
/// <param name="dto"></param>
/// <param name="ct"></param>
/// <returns></returns>
/// <exception cref="KavitaException">Message is not localized</exception>
public async Task<AnnotationDto> UpdateAnnotation(int userId, AnnotationDto dto, CancellationToken ct = default)
{
try
{
var annotation = await unitOfWork.AnnotationRepository.GetAnnotation(dto.Id, ct);
if (annotation == null || annotation.AppUserId != userId) throw new KavitaException("denied");
annotation.ContainsSpoiler = dto.ContainsSpoiler;
annotation.SelectedSlotIndex = dto.SelectedSlotIndex;
annotation.Comment = dto.Comment;
annotation.CommentHtml = dto.CommentHtml;
annotation.CommentPlainText = StripHtml(dto.CommentHtml);
unitOfWork.AnnotationRepository.Update(annotation);
if (!unitOfWork.HasChanges() || await unitOfWork.CommitAsync(ct))
{
dto = (await unitOfWork.AnnotationRepository.GetAnnotationDto(annotation.Id, ct))!;
await eventHub.SendMessageToAsync(MessageFactory.AnnotationUpdate,
MessageFactory.AnnotationUpdateEvent(dto), userId, ct);
return dto;
}View on GitHub (pinned to 9c3e540000)
Solutions
- Confirm the annotation Id being edited is still owned by the current user before issuing the update.
- Stop masking: refactor UpdateAnnotation so KavitaException is rethrown (catch KavitaException { throw; }) so 'denied' reaches the client as a clear 400/403.
- Have the controller translate 'denied' into 403 Forbidden instead of 400 BadRequest for clearer semantics.
Example fix
// before — 'denied' is caught by catch(Exception) and becomes 'generic-error'
if (annotation == null || annotation.AppUserId != userId) throw new KavitaException("denied");
// ...
} catch (Exception ex) { logger.LogError(ex, ...); }
throw new KavitaException("generic-error");
// after — let the authorization result propagate
catch (KavitaException) { throw; }
catch (Exception ex) { logger.LogError(ex, ...); }
throw new KavitaException("generic-error"); Defensive patterns
Strategy: validation
Validate before calling
// Before update, confirm ownership on the client using known owner info
if (annotation.OwnerUserId != currentUserId) { /* do not call update */ }
// Server-side pre-check is what the service itself does; just ensure the id is current. Prevention
- Only enable 'edit' UI for annotations the user owns.
- Refetch the annotation list before editing to avoid stale ids.
- Be aware 'denied' currently surfaces as 'generic-error' due to the catch-all; check logs for the ownership failure.
When it happens
Trigger: POST /api/annotation/update with an annotation Id that does not exist, belongs to another user, or was deleted. Also when a user tries to edit an annotation they only 'liked' rather than own.
Common situations: Concurrent deletion by another session/device; stale UI listing an annotation the user no longer owns; permission model where only the owner may edit and the client mistakenly enables edit for non-owners.
Related errors
AI-assisted analysis of Kareadita/Kavita@9c3e540000 (2026-08-13).
Data as JSON: /api/errors/f4d669d7246ace7c.
Report an issue: GitHub.