Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions config.sample.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ webdav:
# REST API configuration
api:
prefix: '/api' # API endpoint prefix
key_override: '' # 32-char API key override (takes precedence over database key, leave empty to use database)

# Authentication configuration
auth:
Expand Down Expand Up @@ -168,8 +169,12 @@ mount_path: '' # WebDAV mount path, Example: '/mnt/altmount' or '/mnt/unionfs'.
# SABnzbd-compatible API configuration
sabnzbd:
enabled: false # Enable SABnzbd-compatible API
complete_dir: '/complete' # The complete directory where the files will be "downloaded"
categories: # Download categories (optional)
complete_dir: '/' # Base virtual directory (relative to mount point, defaults to root)
categories: # Download categories
- name: 'Default' # System default category (name cannot be changed)
order: 0
priority: 0
dir: 'complete' # Default directory for uncategorized downloads
- name: 'movies'
order: 1
priority: 0
Expand Down
16 changes: 11 additions & 5 deletions frontend/src/components/config/ArrsConfigSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ export function ArrsConfigSection({
index={index}
isReadOnly={isReadOnly}
isApiKeyVisible={isApiKeyVisible}
categories={config.sabnzbd?.categories}
onToggleApiKey={() => toggleApiKeyVisibility(instanceId)}
onRemove={() => removeInstance(type, index)}
onInstanceChange={(field, value) => handleInstanceChange(type, index, field, value)}
Expand Down Expand Up @@ -477,15 +478,20 @@ export function ArrsConfigSection({

<fieldset className="fieldset">
<legend className="fieldset-legend">Download Category (Optional)</legend>
<input
type="text"
className="input"
<select
className="select"
value={newInstance.category}
onChange={(e) =>
setNewInstance((prev) => ({ ...prev, category: e.target.value }))
}
placeholder={newInstance.type === "radarr" ? "movies" : "tv"}
/>
>
<option value="">None</option>
{config.sabnzbd?.categories?.map((cat) => (
<option key={cat.name} value={cat.name}>
{cat.name}
</option>
))}
</select>
</fieldset>

<label className="label cursor-pointer">
Expand Down
19 changes: 13 additions & 6 deletions frontend/src/components/config/ArrsInstanceCard.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { Eye, EyeOff, Trash2 } from "lucide-react";
import { useCallback, useState } from "react";
import type { ArrsInstanceConfig, ArrsType } from "../../types/config";
import type { ArrsInstanceConfig, ArrsType, SABnzbdCategory } from "../../types/config";

interface ArrsInstanceCardProps {
instance: ArrsInstanceConfig;
type: ArrsType;
index: number;
isReadOnly: boolean;
isApiKeyVisible: boolean;
categories?: SABnzbdCategory[];
onToggleApiKey: () => void;
onRemove: () => void;
onInstanceChange: (
Expand All @@ -22,6 +23,7 @@ export function ArrsInstanceCard({
index,
isReadOnly,
isApiKeyVisible,
categories = [],
onToggleApiKey,
onRemove,
onInstanceChange,
Expand Down Expand Up @@ -143,14 +145,19 @@ export function ArrsInstanceCard({

<fieldset className="fieldset">
<legend className="fieldset-legend">Download Category (Optional)</legend>
<input
type="text"
className="input"
<select
className="select"
value={instance.category || ""}
onChange={(e) => handleInstanceChange("category", e.target.value)}
placeholder={type === "radarr" ? "movies" : "tv"}
disabled={isReadOnly}
/>
>
<option value="">None</option>
{categories.map((cat) => (
<option key={cat.name} value={cat.name}>
{cat.name}
</option>
))}
</select>
<p className="label text-base-content/70 text-xs">
SABnzbd category to use for this instance. Defaults to "
{type === "radarr" ? "movies" : "tv"}".
Expand Down
59 changes: 48 additions & 11 deletions frontend/src/components/config/SABnzbdConfigSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ const DEFAULT_NEW_CATEGORY: NewCategoryForm = {
dir: "",
};

const DEFAULT_CATEGORY_NAME = "Default";
const isDefaultCategory = (categoryName: string) => categoryName === DEFAULT_CATEGORY_NAME;

export function SABnzbdConfigSection({
config,
onUpdate,
Expand Down Expand Up @@ -82,11 +85,11 @@ export function SABnzbdConfigSection({
const errors: string[] = [];

if (data.enabled) {
// Validate complete_dir is required and absolute
// Validate complete_dir is required and starts with /
if (!data.complete_dir?.trim()) {
errors.push("Complete directory is required when SABnzbd API is enabled");
} else if (!data.complete_dir.startsWith("/")) {
errors.push("Complete directory must be an absolute path (starting with /)");
errors.push("Complete directory must start with /");
}

// Validate category names are unique
Expand Down Expand Up @@ -134,12 +137,27 @@ export function SABnzbdConfigSection({
};

const handleCategoryUpdate = (index: number, updates: Partial<SABnzbdCategory>) => {
const category = formData.categories[index];
// For Default category, only prevent renaming (allow order, priority, dir changes)
if (isDefaultCategory(category.name) && updates.name !== undefined) {
// Prevent renaming Default category
delete updates.name;
if (Object.keys(updates).length === 0) {
return;
}
}

const categories = [...formData.categories];
categories[index] = { ...categories[index], ...updates };
updateFormData({ categories });
};

const handleRemoveCategory = (index: number) => {
const category = formData.categories[index];
// Prevent removing Default category
if (isDefaultCategory(category.name)) {
return;
}
const categories = formData.categories.filter((_, i) => i !== index);
updateFormData({ categories });
};
Expand All @@ -149,6 +167,12 @@ export function SABnzbdConfigSection({
return;
}

// Prevent creating a category with the reserved name
if (newCategory.name.trim() === DEFAULT_CATEGORY_NAME) {
Comment on lines +170 to +171
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Case-insensitive validation is missing for preventing reserved category name. The check newCategory.name.trim() === DEFAULT_CATEGORY_NAME is case-sensitive, but the backend's EnsureDefaultCategory uses strings.EqualFold (case-insensitive) on line 261 of internal/config/manager.go. This means a user could create a category named "default" or "DEFAULT" in the UI, which would then be normalized to "Default" by the backend, potentially causing duplicate category issues. Consider using a case-insensitive comparison: newCategory.name.trim().toLowerCase() === DEFAULT_CATEGORY_NAME.toLowerCase()

Suggested change
// Prevent creating a category with the reserved name
if (newCategory.name.trim() === DEFAULT_CATEGORY_NAME) {
// Prevent creating a category with the reserved name (case-insensitive to match backend)
if (newCategory.name.trim().toLowerCase() === DEFAULT_CATEGORY_NAME.toLowerCase()) {

Copilot uses AI. Check for mistakes.
setValidationErrors([`"${DEFAULT_CATEGORY_NAME}" is a reserved category name`]);
return;
}

const category: SABnzbdCategory = {
name: newCategory.name.trim(),
order: newCategory.order,
Expand Down Expand Up @@ -231,12 +255,11 @@ export function SABnzbdConfigSection({
className="input"
value={formData.complete_dir}
readOnly={isReadOnly}
placeholder="/mnt/altmount/complete"
placeholder="/"
onChange={(e) => handleCompleteDirChange(e.target.value)}
/>
<p className="label">
Absolute path to the directory where the full imports will be stored, relative to the
mounted folder.
Base virtual directory (relative to mount point, defaults to root).
</p>
</fieldset>

Expand Down Expand Up @@ -346,21 +369,32 @@ export function SABnzbdConfigSection({
<div className="space-y-3">
{formData.categories
.sort((a, b) => a.order - b.order)
.map((category, index) => (
<div key={index} className="card bg-base-200 shadow-sm">
.map((category, index) => {
const isDefault = isDefaultCategory(category.name);
return (
<div key={index} className={`card shadow-sm ${isDefault ? 'bg-base-300 border border-primary/30' : 'bg-base-200'}`}>
<div className="card-body p-4">
{isDefault && (
<div className="badge badge-primary badge-sm mb-2">
System Default (name cannot be changed)
</div>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<fieldset className="fieldset">
<legend className="fieldset-legend">Name</legend>
<input
type="text"
className="input input-sm"
value={category.name}
readOnly={isReadOnly}
readOnly={isReadOnly || isDefault}
disabled={isDefault}
onChange={(e) =>
handleCategoryUpdate(index, { name: e.target.value })
}
/>
{isDefault && (
<p className="label text-xs text-base-content/60">Name is immutable</p>
)}
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">Order</legend>
Expand Down Expand Up @@ -400,12 +434,15 @@ export function SABnzbdConfigSection({
className="input input-sm"
value={category.dir}
readOnly={isReadOnly}
placeholder="Optional subdirectory"
placeholder={isDefault ? "complete" : "Optional subdirectory"}
onChange={(e) => handleCategoryUpdate(index, { dir: e.target.value })}
/>
{isDefault && (
<p className="label text-xs text-base-content/60">Default: complete</p>
)}
</fieldset>
</div>
{!isReadOnly && (
{!isReadOnly && !isDefault && (
<div className="mt-2 flex justify-end">
<button
type="button"
Expand All @@ -419,7 +456,7 @@ export function SABnzbdConfigSection({
)}
</div>
</div>
))}
)})}
</div>
)}

Expand Down
20 changes: 15 additions & 5 deletions frontend/src/components/queue/DragDropUpload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { AlertCircle, CheckCircle2, Download, FileIcon, Link, Upload, X } from "
import { useCallback, useState } from "react";
import { useToast } from "../../contexts/ToastContext";
import { useUploadNZBLnks, useUploadToQueue } from "../../hooks/useApi";
import { useConfig } from "../../hooks/useConfig";
import { ErrorAlert } from "../ui/ErrorAlert";

interface UploadedFile {
Expand Down Expand Up @@ -32,6 +33,10 @@ export function DragDropUpload() {
const uploadMutation = useUploadToQueue();
const uploadLinksMutation = useUploadNZBLnks();
const { showToast } = useToast();
const { data: config } = useConfig();

// Get available categories from config
const categories = config?.sabnzbd?.categories ?? [];

const validateFile = useCallback((file: File): string | null => {
// Check file extension
Expand Down Expand Up @@ -407,13 +412,18 @@ export function DragDropUpload() {
{/* Category Input (shared) */}
<fieldset className="fieldset mb-4">
<legend className="fieldset-legend">Category (optional)</legend>
<input
type="text"
className="input"
placeholder="e.g., movies, tv, software"
<select
className="select"
value={category}
onChange={(e) => setCategory(e.target.value)}
/>
>
<option value="">None</option>
{categories.map((cat) => (
<option key={cat.name} value={cat.name}>
{cat.name}
</option>
))}
</select>
<p className="label">Category will be applied to all uploaded items</p>
</fieldset>

Expand Down
21 changes: 21 additions & 0 deletions internal/api/auth_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,27 @@ func (s *Server) handleRegenerateAPIKey(c *fiber.Ctx) error {
return RespondInternalError(c, "Failed to regenerate API key", err.Error())
}

// If key_override is configured (has a value with 32 chars), update it with the new key
if s.configManager != nil {
cfg := s.configManager.GetConfig()
if cfg.API.KeyOverride != "" && len(cfg.API.KeyOverride) == 32 {
// Update the key_override in config to match the new key
newConfig := cfg.DeepCopy()
newConfig.API.KeyOverride = apiKey

if err := s.configManager.UpdateConfig(newConfig); err != nil {
slog.WarnContext(c.Context(), "Failed to update key_override in config", "error", err)
// Don't fail the request, just log the warning
} else {
if err := s.configManager.SaveConfig(); err != nil {
slog.WarnContext(c.Context(), "Failed to save config after updating key_override", "error", err)
} else {
slog.InfoContext(c.Context(), "Updated key_override in config with new API key")
}
}
}
}

response := fiber.Map{
"api_key": apiKey,
"message": "API key regenerated successfully",
Expand Down
17 changes: 17 additions & 0 deletions internal/api/parsers.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,24 @@ func ParseTimeParamFiber(c *fiber.Ctx, param string) (*time.Time, error) {
}

// validateAPIKey validates the API key using AltMount's authentication system
// First checks if there's a key_override in config (must be exactly 32 characters)
// Then falls back to checking the database
func (s *Server) validateAPIKey(c *fiber.Ctx, apiKey string) bool {
if apiKey == "" {
return false
}

// Check config key_override first (must be exactly 32 characters to be valid)
if s.configManager != nil {
cfg := s.configManager.GetConfig()
if cfg.API.KeyOverride != "" && len(cfg.API.KeyOverride) == 32 {
if apiKey == cfg.API.KeyOverride {
return true
}
}
}

// Fall back to database validation
if s.userRepo == nil {
return false
}
Expand Down
Loading
Loading