diff --git a/README.md b/README.md index 217b126..79db3ae 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Omnihance A3 Agent is a full-stack application consisting of: - Cannot edit files or upload data - **Permission Actions**: - `view_files`: View file system and file contents (super_admin, admin, viewer) + - `download_files`: Create and use one-day user-bound download links for file-browser and backup output files through `POST /api/file-tree/download-link` and `GET /api/file-tree/download/{token}` (super_admin, admin) - `edit_files`: Edit files (super_admin, admin) - `revert_files`: Revert files to previous revisions (super_admin, admin) - `upload_game_data`: Upload MON.ull and MC.ull files (super_admin, admin) @@ -576,6 +577,8 @@ Only stable GitHub releases are considered because GitHub's latest release endpo - `POST /api/file-tree/revert-file` - Revert file to previous revision - `POST /api/file-tree/duplicate-file` - Duplicate a file in the same directory - `GET /api/file-tree/revision-summary` - Get revision count for a file +- `POST /api/file-tree/download-link` - Create or reuse a one-day user-bound download link for a file (requires `download_files` permission) +- `GET /api/file-tree/download/{token}` - Download a file through a valid user-bound temp link and record the download event ### Metrics @@ -659,7 +662,8 @@ Only stable GitHub releases are considered because GitHub's latest release endpo - `POST /api/backups/jobs/{id}/cancel` - Cancel a running backup job - `GET /api/backups/jobs/{id}/runs` - List paginated run history for a backup job - `GET /api/backups/runs/{run_id}` - Get run details, logs, errors, and output file metadata -- `GET /api/backups/runs/{run_id}/files/{file_id}/download` - Download a backup output file +- `POST /api/backups/runs/{run_id}/files/{file_id}/download-link` - Create or reuse a one-day user-bound download link for a successful backup output file (requires `download_files` permission) +- `GET /api/backups/runs/{run_id}/files/{file_id}/download` - Compatibility route that redirects successful backup output downloads through the temp-link flow - `GET /api/backups/path-search` - Search local source or destination paths - `GET /api/backups/defaults/sql-server` - Get SQL Server backup defaults and local server status @@ -686,6 +690,8 @@ The application uses SQLite with the following main tables: - **backup_jobs**: Backup job definitions, scheduling metadata, paths, SQL settings, and statuses - **backup_runs**: Backup run history, trigger type, status, output logs, errors, and cancellation timestamps - **backup_run_files**: Output archive files created by each backup run +- **file_download_links**: One-day user-bound file download links, file fingerprints, source context, and per-link download counts +- **file_download_events**: Per-request file download audit events with user, link, source context, file fingerprint, IP address, and user agent - **server_view_svr_info_rows**: Raw `SvrInfo.ini` rows for Main, Account, Zone, and Battle servers - **server_view_map_zones**: Raw Main and Zone Server map-to-zone references - **server_view_spawn_rows**: Raw monster spawn rows from zone map `.n_ndt` files @@ -716,7 +722,7 @@ The application uses SQLite with the following main tables: 5. **Upload Game Client Data**: Navigate to the Client Data section and upload MON.ull, MC.ull, and IT0.ull through IT3.ull files to populate monster, map, and item databases (requires admin or super admin role). -6. **Navigate Files**: Use the file tree sidebar to browse your server's file system (all authenticated users can view). Pin frequently used directories as shortcuts and right-click files to duplicate them quickly. +6. **Navigate Files**: Use the file tree sidebar to browse your server's file system (all authenticated users can view). Pin frequently used directories as shortcuts, right-click files to duplicate them, and download files with admin or super admin access. 7. **Edit Files**: Click on editable files (NPC files, quest files, spawn files, drop files (monster drop configurations), item files, item combination data files, or text files) to view and edit them (requires admin or super admin role). - **Quest Files**: Edit quest configurations with type-aware objectives, add/remove controls for optional slots, and binary-safe padding preservation diff --git a/cmd/omnihance-a3-agent/docs/openapi.yml b/cmd/omnihance-a3-agent/docs/openapi.yml index 41d1956..8c0944c 100644 --- a/cmd/omnihance-a3-agent/docs/openapi.yml +++ b/cmd/omnihance-a3-agent/docs/openapi.yml @@ -286,6 +286,110 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + /api/file-tree/download-link: + post: + tags: + - file-system + summary: Create file download link + description: Creates or reuses a one-day temp download link for the authenticated user when the file fingerprint is unchanged and the existing link has not expired. + security: + - ApiKeyAuth: [] + parameters: + - in: query + name: path + required: true + schema: + type: string + description: File path to download. Directories are rejected. + responses: + '200': + description: Download link created or reused + content: + application/json: + schema: + $ref: '#/components/schemas/DownloadLinkResponse' + '400': + description: Bad Request - Missing path or path is a directory + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Download files permission is required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: File not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/file-tree/download/{token}: + get: + tags: + - file-system + summary: Download file from temp link + description: Streams the file as an attachment when the signed token belongs to the authenticated user, has not expired, and the file fingerprint is unchanged. A successful request increments the link download count and records a download event. + security: + - ApiKeyAuth: [] + parameters: + - name: token + in: path + required: true + schema: + type: string + responses: + '200': + description: File download + content: + application/octet-stream: + schema: + type: string + format: binary + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Invalid token, wrong user, or missing download permission + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Download link or file not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '410': + description: Download link expired or invalidated because the file changed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/file-tree/npc-file: get: tags: @@ -3985,7 +4089,66 @@ paths: tags: - backups summary: Download a backup output file - description: Downloads one output file recorded for a backup run. The file ID must belong to the requested run. + description: Compatibility route that validates the requested successful backup output file, creates or reuses a user-bound temp link, and redirects to the shared file download endpoint. + security: + - ApiKeyAuth: [] + parameters: + - name: run_id + in: path + required: true + schema: + type: integer + format: int64 + - name: file_id + in: path + required: true + schema: + type: integer + format: int64 + responses: + '302': + description: Redirect to temp download link + headers: + Location: + schema: + type: string + description: Shared temp download URL under /api/file-tree/download/{token} + '400': + description: Bad Request - Invalid backup run or file ID, failed run, or backup output is a directory + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Download files permission is required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Backup run file not found or output file is missing + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/backups/runs/{run_id}/files/{file_id}/download-link: + post: + tags: + - backups + summary: Create backup output download link + description: Creates or reuses a one-day user-bound temp download link for a file recorded on a successful backup run. The output file must still exist and must not be a directory. security: - ApiKeyAuth: [] parameters: @@ -4003,14 +4166,13 @@ paths: format: int64 responses: '200': - description: Backup file download + description: Download link created or reused content: - application/octet-stream: + application/json: schema: - type: string - format: binary + $ref: '#/components/schemas/DownloadLinkResponse' '400': - description: Bad Request - Invalid backup run or file ID, or backup output is a directory + description: Bad Request - Invalid backup run or file ID, failed run, or backup output is a directory content: application/json: schema: @@ -4022,7 +4184,7 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' '403': - description: Forbidden - Manage server permission is required + description: Forbidden - Download files permission is required content: application/json: schema: @@ -4341,6 +4503,24 @@ components: file_tree: $ref: '#/components/schemas/FileNode' description: The file system tree structure + DownloadLinkResponse: + type: object + properties: + download_url: + type: string + description: Shared temp download URL for the authenticated user + example: '/api/file-tree/download/01HZX.temp-token' + expires_at: + type: string + format: date-time + description: Link expiration timestamp + reused: + type: boolean + description: Whether an existing unexpired link was reused + download_count: + type: integer + format: int64 + description: Number of successful download requests already recorded for this link NPCFileAPIData: type: object description: Parsed binary data from an NPC file (API request/response format). All fields are required when used as a request body. @@ -6285,6 +6465,9 @@ components: created_at: type: string format: date-time + download_available: + type: boolean + description: True when the run succeeded and the recorded output file still exists as a file BackupRunDetails: type: object properties: diff --git a/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/README.md b/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/README.md index 32d2e6c..bc1c3a1 100644 --- a/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/README.md +++ b/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/README.md @@ -2,6 +2,8 @@ Omnihance A3 Agent frontend +File-browser and backup output downloads use the backend temp-link flow. Admin and super admin users can create one-day user-bound links; viewers see file-browser download actions but receive the denial toast. + ## Adding Shadcn Components Run `pnpm dlx shadcn@latest add {component-name}` diff --git a/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/backup-page.tsx b/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/backup-page.tsx index 4f9084a..445e85d 100644 --- a/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/backup-page.tsx +++ b/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/backup-page.tsx @@ -81,11 +81,11 @@ import { APIError, cancelBackupJob, createBackupJob, + createBackupRunFileDownloadLink, deleteBackupJob, getBackupJob, getBackupJobs, getBackupRunDetails, - getBackupRunFileDownloadUrl, getBackupRuns, getBackupSQLServerDefaults, runBackupJob, @@ -1180,6 +1180,29 @@ function RunDetailsDialog({ open: boolean; onOpenChange: (open: boolean) => void; }) { + const downloadMutation = useMutation({ + mutationFn: ({ + runId, + fileId, + }: { + runId: number; + fileId: number; + itemName: string; + }) => createBackupRunFileDownloadLink(runId, fileId), + onSuccess: (response) => { + window.location.assign(response.download_url); + }, + onError: (error) => { + const errorMessage = + error instanceof APIError + ? error.getErrorMessage() + : error instanceof Error + ? error.message + : 'Failed to create download link'; + toast.error(errorMessage); + }, + }); + return ( @@ -1229,22 +1252,32 @@ function RunDetailsDialog({
{formatBytes(file.file_size, 1)}
- + {downloadMutation.isPending && + downloadMutation.variables?.fileId === file.id ? ( + + ) : ( + + )} + + )} ))} diff --git a/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/file-tree.tsx b/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/file-tree.tsx index 37ba84b..862b6e7 100644 --- a/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/file-tree.tsx +++ b/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/file-tree.tsx @@ -52,6 +52,7 @@ import { getDirectoryShortcuts, createDirectoryShortcut, duplicateFile as duplicateFileAPI, + createFileDownloadLink, APIError, type FileNode, type FileTreeResponse, @@ -73,6 +74,7 @@ interface ProcessContextMenuWrapperProps { fileItem: React.ReactElement; canManageServer: boolean; onDuplicate: (item: FileNode) => void; + onDownload: (item: FileNode) => void; onAdd: () => void; onRemove: () => void; } @@ -83,6 +85,7 @@ function ProcessContextMenuWrapper({ fileItem, canManageServer, onDuplicate, + onDownload, onAdd, onRemove, }: ProcessContextMenuWrapperProps) { @@ -104,6 +107,11 @@ function ProcessContextMenuWrapper({ Duplicate )} + {item.kind === 'file' && ( + onDownload(item)}> + Download + + )} {canManageServer && item.kind === 'file' && (existingProcess ? ( @@ -131,6 +139,7 @@ export function FileTree({ initialPath }: FileTreeProps) { const queryClient = useQueryClient(); const { hasPermission } = usePermissions(); const canManageServer = hasPermission('manage_server'); + const canDownloadFiles = hasPermission('download_files'); const [internalPath, setInternalPath] = useState(null); const [showDotfiles, setShowDotfiles] = useState(false); const contextMenuFileRef = useRef(null); @@ -494,6 +503,25 @@ export function FileTree({ initialPath }: FileTreeProps) { }, }); + const downloadFileMutation = useMutation({ + mutationFn: async (path: string) => { + return createFileDownloadLink({ path }); + }, + onSuccess: (response) => { + window.location.assign(response.download_url); + }, + onError: (error) => { + const errorMessage = + error instanceof APIError + ? error.getErrorMessage() + : error instanceof Error + ? error.message + : 'Failed to create download link'; + + toast.error(errorMessage); + }, + }); + const generateSuggestedName = (): string => { if (!currentPath || currentPath === '') { return ''; @@ -574,6 +602,19 @@ export function FileTree({ initialPath }: FileTreeProps) { }); }; + const handleDownloadFile = (item: FileNode) => { + if (item.kind !== 'file') { + return; + } + + if (!canDownloadFiles) { + toast.error('You cannot download this file'); + return; + } + + downloadFileMutation.mutate(getFullPath(item)); + }; + const isCurrentPathInShortcuts = (): boolean => { if (!currentPath || currentPath === '') { return false; @@ -824,6 +865,7 @@ export function FileTree({ initialPath }: FileTreeProps) { fileItem={fileItem} canManageServer={Boolean(isExecutable && canManageServer)} onDuplicate={handleOpenDuplicateDialog} + onDownload={handleDownloadFile} onAdd={() => handleAddToServer(item)} onRemove={() => handleRemoveFromServer(item)} /> diff --git a/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/file-view.tsx b/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/file-view.tsx index 5b34b91..2faed84 100644 --- a/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/file-view.tsx +++ b/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/file-view.tsx @@ -13,6 +13,7 @@ import { Edit, RotateCcw, History, + Download, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -39,6 +40,7 @@ import { getItemCombinationDataFile, getQuestFile, getRevisionSummary, + createFileDownloadLink, revertFile, getMaps, type ItemFileNameEncoding, @@ -62,6 +64,7 @@ export function FileView({ filePath }: FileViewProps) { const { hasPermission } = usePermissions(); const canEditFiles = hasPermission('edit_files'); const canRevertFiles = hasPermission('revert_files'); + const canDownloadFiles = hasPermission('download_files'); const [itemNameEncodingState, setItemNameEncodingState] = useState<{ filePath: string; encoding?: ItemFileNameEncoding; @@ -261,6 +264,32 @@ export function FileView({ filePath }: FileViewProps) { }, }); + const downloadMutation = useMutation({ + mutationFn: () => { + return createFileDownloadLink({ path: filePath }); + }, + onSuccess: (response) => { + window.location.assign(response.download_url); + }, + onError: (error) => { + const errorMessage = + error instanceof APIError + ? error.getErrorMessage() + : error instanceof Error + ? error.message + : 'Failed to create download link'; + toast.error(errorMessage); + }, + }); + + const handleDownload = () => { + downloadMutation.mutate(); + }; + + const handleDownloadUnavailable = () => { + toast.error('You cannot download this file'); + }; + const fileTreeErrorMessage = fileTreeError instanceof APIError ? fileTreeError.getErrorMessage() @@ -334,6 +363,26 @@ export function FileView({ filePath }: FileViewProps) {

{filePath}

+ {fileNode && canDownloadFiles && ( + + )} + {fileNode && !canDownloadFiles && ( + + )} {isEditable && canRevertFiles && revisionSummary && diff --git a/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/hooks/use-permissions.ts b/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/hooks/use-permissions.ts index ffd3d1a..8ab974a 100644 --- a/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/hooks/use-permissions.ts +++ b/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/hooks/use-permissions.ts @@ -7,6 +7,7 @@ type PermissionAction = | 'view_files' | 'edit_files' | 'revert_files' + | 'download_files' | 'upload_game_data' | 'manage_users' | 'view_metrics' @@ -17,6 +18,7 @@ const rolePermissions: Record = { view_files: ['super_admin', 'admin', 'viewer'], edit_files: ['super_admin', 'admin'], revert_files: ['super_admin', 'admin'], + download_files: ['super_admin', 'admin'], upload_game_data: ['super_admin', 'admin'], manage_users: ['super_admin'], view_metrics: ['super_admin', 'admin', 'viewer'], diff --git a/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/lib/api.ts b/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/lib/api.ts index 271202a..bd89598 100644 --- a/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/lib/api.ts +++ b/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/lib/api.ts @@ -21,6 +21,7 @@ export const API_ROUTES = { REVERT_FILE: '/api/file-tree/revert-file', DUPLICATE_FILE: '/api/file-tree/duplicate-file', REVISION_COUNT: '/api/file-tree/revision-summary', + FILE_DOWNLOAD_LINK: '/api/file-tree/download-link', METRICS_SUMMARY: '/api/metrics/summary', METRICS_CHARTS: '/api/metrics/charts', GAME_CLIENT_DATA_MONSTERS: '/api/game-client-data/monsters', @@ -63,6 +64,8 @@ export const API_ROUTES = { BACKUP_RUN: (id: number) => `/api/backups/runs/${id}`, BACKUP_RUN_FILE_DOWNLOAD: (runId: number, fileId: number) => `/api/backups/runs/${runId}/files/${fileId}/download`, + BACKUP_RUN_FILE_DOWNLOAD_LINK: (runId: number, fileId: number) => + `/api/backups/runs/${runId}/files/${fileId}/download-link`, BACKUP_PATH_SEARCH: '/api/backups/path-search', BACKUP_SQL_SERVER_DEFAULTS: '/api/backups/defaults/sql-server', SERVER_VIEW: '/api/server-view', @@ -368,6 +371,15 @@ const DuplicateFileResponseSchema = z.object({ export type DuplicateFileResponse = z.infer; +const DownloadLinkResponseSchema = z.object({ + download_url: z.string(), + expires_at: z.string(), + reused: z.boolean(), + download_count: z.number().int().nonnegative(), +}); + +export type DownloadLinkResponse = z.infer; + const TextFileAPIDataSchema = z.object({ content: z.string(), }); @@ -1111,6 +1123,21 @@ export async function duplicateFile( ); } +export async function createFileDownloadLink(params: { + path: string; +}): Promise { + const response = await axiosInstance.post( + API_ROUTES.FILE_DOWNLOAD_LINK, + undefined, + { params }, + ); + return validateResponse( + DownloadLinkResponseSchema, + response.data, + API_ROUTES.FILE_DOWNLOAD_LINK, + ); +} + export async function getMetricsSummary(): Promise { const response = await axiosInstance.get(API_ROUTES.METRICS_SUMMARY); return validateResponse( @@ -2096,6 +2123,7 @@ const BackupRunFileSchema = z.object({ file_path: z.string(), file_size: z.number().int(), created_at: z.string(), + download_available: z.boolean(), }); export type BackupRunFile = z.infer; @@ -2245,6 +2273,15 @@ export function getBackupRunFileDownloadUrl( return API_ROUTES.BACKUP_RUN_FILE_DOWNLOAD(runId, fileId); } +export async function createBackupRunFileDownloadLink( + runId: number, + fileId: number, +): Promise { + const route = API_ROUTES.BACKUP_RUN_FILE_DOWNLOAD_LINK(runId, fileId); + const response = await axiosInstance.post(route); + return validateResponse(DownloadLinkResponseSchema, response.data, route); +} + export async function searchBackupPaths(params: { query?: string; kind?: 'input' | 'directory'; diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 27e2a9c..af02881 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -1,5 +1,9 @@ package constants +import "errors" + +var ErrNotFound = errors.New("not found") + const ( ErrorCodeUnauthorized = "UNAUTHORIZED" ErrorCodeForbidden = "FORBIDDEN" diff --git a/internal/db/file_downloads.go b/internal/db/file_downloads.go new file mode 100644 index 0000000..fbab58f --- /dev/null +++ b/internal/db/file_downloads.go @@ -0,0 +1,233 @@ +package db + +import ( + "fmt" + "time" + + "github.com/doug-martin/goqu/v9" + "github.com/doug-martin/goqu/v9/exp" + "github.com/omnihance/omnihance-a3-agent/internal/constants" + "github.com/omnihance/omnihance-a3-agent/internal/logger" +) + +const ( + FileDownloadSourceFileBrowser = "file_browser" + FileDownloadSourceBackup = "backup" +) + +type FileDownloadLink struct { + ID int64 `db:"id" json:"id"` + PublicID string `db:"public_id" json:"public_id"` + UserID int64 `db:"user_id" json:"user_id"` + FileID string `db:"file_id" json:"file_id"` + SourceType string `db:"source_type" json:"source_type"` + BackupRunID *int64 `db:"backup_run_id" json:"backup_run_id"` + BackupFileID *int64 `db:"backup_file_id" json:"backup_file_id"` + OriginalPath string `db:"original_path" json:"original_path"` + FileName string `db:"file_name" json:"file_name"` + FileSize int64 `db:"file_size" json:"file_size"` + FileHash string `db:"file_hash" json:"file_hash"` + FileModifiedAt int64 `db:"file_modified_at" json:"file_modified_at"` + ExpiresAt time.Time `db:"expires_at" json:"expires_at"` + DownloadCount int64 `db:"download_count" json:"download_count"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + LastDownloadedAt *time.Time `db:"last_downloaded_at" json:"last_downloaded_at"` +} + +type FileDownloadLinkPayload struct { + PublicID string + UserID int64 + FileID string + SourceType string + BackupRunID *int64 + BackupFileID *int64 + OriginalPath string + FileName string + FileSize int64 + FileHash string + FileModifiedAt int64 + ExpiresAt time.Time +} + +func (s *sqliteInternalDB) GetReusableFileDownloadLink(payload FileDownloadLinkPayload, now time.Time) (*FileDownloadLink, error) { + var link FileDownloadLink + query := s.goqu.From("file_download_links"). + Prepared(true). + Where( + goqu.Ex{ + "user_id": payload.UserID, + "file_id": payload.FileID, + "source_type": payload.SourceType, + "original_path": payload.OriginalPath, + "file_size": payload.FileSize, + "file_hash": payload.FileHash, + "file_modified_at": payload.FileModifiedAt, + }, + goqu.C("expires_at").Gt(now), + nullableInt64Condition("backup_run_id", payload.BackupRunID), + nullableInt64Condition("backup_file_id", payload.BackupFileID), + ). + Order(goqu.C("created_at").Desc(), goqu.C("id").Desc()). + Limit(1) + + found, err := query.ScanStruct(&link) + if err != nil { + s.logger.Error( + "failed to get reusable file download link", + logger.Field{Key: "file_id", Value: payload.FileID}, + logger.Field{Key: "user_id", Value: payload.UserID}, + logger.Field{Key: "error", Value: err}, + ) + return nil, fmt.Errorf("failed to get reusable file download link: %w", err) + } + + if !found { + return nil, nil + } + + return &link, nil +} + +func (s *sqliteInternalDB) CreateFileDownloadLink(payload FileDownloadLinkPayload) (*FileDownloadLink, error) { + result, err := s.goqu.Insert("file_download_links"). + Prepared(true). + Rows(fileDownloadLinkRecord(payload)). + Executor(). + Exec() + if err != nil { + s.logger.Error( + "failed to create file download link", + logger.Field{Key: "file_id", Value: payload.FileID}, + logger.Field{Key: "user_id", Value: payload.UserID}, + logger.Field{Key: "error", Value: err}, + ) + return nil, fmt.Errorf("failed to create file download link: %w", err) + } + + id, err := result.LastInsertId() + if err != nil { + return nil, fmt.Errorf("failed to get file download link id: %w", err) + } + + return s.getFileDownloadLink(goqu.Ex{"id": id}) +} + +func (s *sqliteInternalDB) GetFileDownloadLinkByPublicID(publicID string) (*FileDownloadLink, error) { + return s.getFileDownloadLink(goqu.Ex{"public_id": publicID}) +} + +func (s *sqliteInternalDB) RecordFileDownload(link *FileDownloadLink, userID int64, userAgent *string, ipAddress *string) error { + tx, err := s.BeginTx() + if err != nil { + return err + } + defer func() { + _ = tx.Rollback() + }() + + _, err = tx.Insert("file_download_events"). + Prepared(true). + Rows(goqu.Record{ + "link_id": link.ID, + "user_id": userID, + "file_id": link.FileID, + "source_type": link.SourceType, + "backup_run_id": link.BackupRunID, + "backup_file_id": link.BackupFileID, + "original_path": link.OriginalPath, + "file_hash": link.FileHash, + "user_agent": userAgent, + "ip_address": ipAddress, + }). + Executor(). + Exec() + if err != nil { + s.logger.Error( + "failed to record file download event", + logger.Field{Key: "link_id", Value: link.ID}, + logger.Field{Key: "user_id", Value: userID}, + logger.Field{Key: "error", Value: err}, + ) + return fmt.Errorf("failed to record file download event: %w", err) + } + + result, err := tx.Update("file_download_links"). + Prepared(true). + Set(goqu.Record{ + "download_count": goqu.L("download_count + 1"), + "last_downloaded_at": goqu.L("CURRENT_TIMESTAMP"), + }). + Where(goqu.Ex{"id": link.ID}). + Executor(). + Exec() + if err != nil { + s.logger.Error( + "failed to update file download count", + logger.Field{Key: "link_id", Value: link.ID}, + logger.Field{Key: "error", Value: err}, + ) + return fmt.Errorf("failed to update file download count: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get file download count rows affected: %w", err) + } + + if rowsAffected == 0 { + return fmt.Errorf("file download link %d not found", link.ID) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit file download event: %w", err) + } + + return nil +} + +func (s *sqliteInternalDB) getFileDownloadLink(where goqu.Ex) (*FileDownloadLink, error) { + var link FileDownloadLink + found, err := s.goqu.From("file_download_links"). + Prepared(true). + Where(where). + ScanStruct(&link) + if err != nil { + s.logger.Error( + "failed to get file download link", + logger.Field{Key: "where", Value: where}, + logger.Field{Key: "error", Value: err}, + ) + return nil, fmt.Errorf("failed to get file download link: %w", err) + } + + if !found { + return nil, constants.ErrNotFound + } + + return &link, nil +} + +func fileDownloadLinkRecord(payload FileDownloadLinkPayload) goqu.Record { + return goqu.Record{ + "public_id": payload.PublicID, + "user_id": payload.UserID, + "file_id": payload.FileID, + "source_type": payload.SourceType, + "backup_run_id": payload.BackupRunID, + "backup_file_id": payload.BackupFileID, + "original_path": payload.OriginalPath, + "file_name": payload.FileName, + "file_size": payload.FileSize, + "file_hash": payload.FileHash, + "file_modified_at": payload.FileModifiedAt, + "expires_at": payload.ExpiresAt, + } +} + +func nullableInt64Condition(column string, value *int64) exp.Expression { + if value == nil { + return goqu.C(column).IsNull() + } + + return goqu.Ex{column: *value} +} diff --git a/internal/db/file_downloads_test.go b/internal/db/file_downloads_test.go new file mode 100644 index 0000000..89f5e39 --- /dev/null +++ b/internal/db/file_downloads_test.go @@ -0,0 +1,112 @@ +package db + +import ( + "io" + "path/filepath" + "testing" + "time" + + "github.com/doug-martin/goqu/v9" + "github.com/omnihance/omnihance-a3-agent/internal/constants" + "github.com/omnihance/omnihance-a3-agent/internal/logger" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" +) + +func TestFileDownloadLinksReuseExpiryAndChangedFingerprint(t *testing.T) { + internalDB := newFileDownloadTestDB(t) + user, err := internalDB.CreateUser("download-admin@example.com", "password", constants.RoleAdmin, nil) + require.NoError(t, err) + + now := time.Now().UTC() + payload := testFileDownloadPayload(user.ID, "file-hash", now.Add(time.Hour)) + link, err := internalDB.CreateFileDownloadLink(payload) + require.NoError(t, err) + require.Equal(t, int64(0), link.DownloadCount) + + reusable, err := internalDB.GetReusableFileDownloadLink(payload, now) + require.NoError(t, err) + require.NotNil(t, reusable) + require.Equal(t, link.ID, reusable.ID) + + reusable, err = internalDB.GetReusableFileDownloadLink(payload, now.Add(2*time.Hour)) + require.NoError(t, err) + require.Nil(t, reusable) + + changedPayload := payload + changedPayload.FileHash = "changed-hash" + reusable, err = internalDB.GetReusableFileDownloadLink(changedPayload, now) + require.NoError(t, err) + require.Nil(t, reusable) +} + +func TestRecordFileDownloadIncrementsCountAndStoresEvent(t *testing.T) { + internalDB := newFileDownloadTestDB(t) + sqliteDB := internalDB.(*sqliteInternalDB) + user, err := internalDB.CreateUser("download-event-admin@example.com", "password", constants.RoleAdmin, nil) + require.NoError(t, err) + + link, err := internalDB.CreateFileDownloadLink(testFileDownloadPayload(user.ID, "file-hash", time.Now().UTC().Add(time.Hour))) + require.NoError(t, err) + + userAgent := "test-agent" + ipAddress := "127.0.0.1" + require.NoError(t, internalDB.RecordFileDownload(link, user.ID, &userAgent, &ipAddress)) + + updatedLink, err := internalDB.GetFileDownloadLinkByPublicID(link.PublicID) + require.NoError(t, err) + require.Equal(t, int64(1), updatedLink.DownloadCount) + require.NotNil(t, updatedLink.LastDownloadedAt) + + var eventCount int64 + _, err = sqliteDB.goqu.From("file_download_events"). + Where(goqu.Ex{ + "link_id": link.ID, + "user_id": user.ID, + "user_agent": userAgent, + "ip_address": ipAddress, + "source_type": FileDownloadSourceFileBrowser, + "original_path": link.OriginalPath, + }). + Select(goqu.COUNT("*")). + ScanVal(&eventCount) + require.NoError(t, err) + require.Equal(t, int64(1), eventCount) +} + +func TestGetFileDownloadLinkByPublicIDReturnsNotFoundSentinel(t *testing.T) { + internalDB := newFileDownloadTestDB(t) + + link, err := internalDB.GetFileDownloadLinkByPublicID("missing-link") + require.Nil(t, link) + require.ErrorIs(t, err, constants.ErrNotFound) +} + +func newFileDownloadTestDB(t *testing.T) InternalDB { + t.Helper() + + log := logger.NewZerologLogger(zerolog.New(io.Discard), "test", zerolog.Disabled) + internalDB := NewSQLiteDB(filepath.Join(t.TempDir(), "test.db"), log) + require.NoError(t, internalDB.Connect()) + require.NoError(t, internalDB.MigrateUp()) + t.Cleanup(func() { + require.NoError(t, internalDB.Close()) + }) + + return internalDB +} + +func testFileDownloadPayload(userID int64, fileHash string, expiresAt time.Time) FileDownloadLinkPayload { + return FileDownloadLinkPayload{ + PublicID: "public-" + fileHash + "-" + expiresAt.Format("150405"), + UserID: userID, + FileID: "file-id", + SourceType: FileDownloadSourceFileBrowser, + OriginalPath: filepath.Clean("C:/a3/server/test.dat"), + FileName: "test.dat", + FileSize: 4, + FileHash: fileHash, + FileModifiedAt: 10, + ExpiresAt: expiresAt, + } +} diff --git a/internal/db/internal_db.go b/internal/db/internal_db.go index 1f1ba71..c48d84a 100644 --- a/internal/db/internal_db.go +++ b/internal/db/internal_db.go @@ -50,6 +50,10 @@ type InternalDB interface { GetLastCompletedFileRevision(fileID string) (*FileRevision, error) GetCompletedRevisionCount(fileID string) (int64, error) GetRevisionSummary(fileID string) (*RevisionSummary, error) + GetReusableFileDownloadLink(payload FileDownloadLinkPayload, now time.Time) (*FileDownloadLink, error) + CreateFileDownloadLink(payload FileDownloadLinkPayload) (*FileDownloadLink, error) + GetFileDownloadLinkByPublicID(publicID string) (*FileDownloadLink, error) + RecordFileDownload(link *FileDownloadLink, userID int64, userAgent *string, ipAddress *string) error CreateSession(userID int64, expiresAt time.Time, userAgent, ipAddress *string) (*Session, error) GetSession(sessionID string) (*Session, error) UpdateSessionLastAccessed(sessionID string) error @@ -282,10 +286,18 @@ func (s *sqliteInternalDB) MigrateUp() error { return err } + if err := s.migrate014FileDownloadLinksTable(); err != nil { + return err + } + return nil } func (s *sqliteInternalDB) MigrateDown() error { + if err := s.rollback014FileDownloadLinksTable(); err != nil { + return err + } + if err := s.rollback013ServerViewTables(); err != nil { return err } @@ -1963,3 +1975,162 @@ func (s *sqliteInternalDB) rollback013ServerViewTables() error { return nil } + +func (s *sqliteInternalDB) migrate014FileDownloadLinksTable() error { + const migName = "014_file_download_links" + + applied, err := s.isMigrationApplied(migName) + if err != nil { + s.logger.Error( + "failed to check migration status", + logger.Field{Key: "migration", Value: migName}, + logger.Field{Key: "error", Value: err}, + ) + return fmt.Errorf("failed to check migration status for %s: %w", migName, err) + } + + if applied { + return nil + } + + s.logger.Info("Applying migration", logger.Field{Key: "migration", Value: migName}) + + migrationSQL := ` + CREATE TABLE IF NOT EXISTS file_download_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + public_id TEXT NOT NULL UNIQUE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + file_id TEXT NOT NULL, + source_type TEXT NOT NULL, + backup_run_id INTEGER REFERENCES backup_runs(id) ON DELETE CASCADE, + backup_file_id INTEGER REFERENCES backup_run_files(id) ON DELETE CASCADE, + original_path TEXT NOT NULL, + file_name TEXT NOT NULL, + file_size INTEGER NOT NULL, + file_hash TEXT NOT NULL, + file_modified_at INTEGER NOT NULL, + expires_at TIMESTAMP NOT NULL, + download_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_downloaded_at TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_file_download_links_reuse ON file_download_links ( + user_id, + source_type, + file_id, + file_hash, + file_size, + file_modified_at, + expires_at + ); + + CREATE INDEX IF NOT EXISTS idx_file_download_links_backup ON file_download_links (backup_run_id, backup_file_id); + + CREATE INDEX IF NOT EXISTS idx_file_download_links_expires_at ON file_download_links (expires_at); + + CREATE TABLE IF NOT EXISTS file_download_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + link_id INTEGER NOT NULL REFERENCES file_download_links(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + file_id TEXT NOT NULL, + source_type TEXT NOT NULL, + backup_run_id INTEGER REFERENCES backup_runs(id) ON DELETE CASCADE, + backup_file_id INTEGER REFERENCES backup_run_files(id) ON DELETE CASCADE, + original_path TEXT NOT NULL, + file_hash TEXT NOT NULL, + downloaded_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + user_agent TEXT, + ip_address TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_file_download_events_link_id ON file_download_events (link_id); + + CREATE INDEX IF NOT EXISTS idx_file_download_events_user_id ON file_download_events (user_id); + + CREATE INDEX IF NOT EXISTS idx_file_download_events_file_id ON file_download_events (file_id); + + CREATE INDEX IF NOT EXISTS idx_file_download_events_downloaded_at ON file_download_events (downloaded_at); + ` + + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("failed to begin file download links migration: %w", err) + } + defer func() { + _ = tx.Rollback() + }() + + _, err = tx.Exec(migrationSQL) + if err != nil { + return fmt.Errorf("failed to create file download links tables: %w", err) + } + + if _, err := tx.Exec("INSERT INTO migrations (name) VALUES (?)", migName); err != nil { + s.logger.Error( + "failed to mark migration as applied", + logger.Field{Key: "migration", Value: migName}, + logger.Field{Key: "error", Value: err}, + ) + return fmt.Errorf("failed to mark migration as applied: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit file download links migration: %w", err) + } + + return nil +} + +func (s *sqliteInternalDB) rollback014FileDownloadLinksTable() error { + const migName = "014_file_download_links" + + applied, err := s.isMigrationApplied(migName) + if err != nil { + s.logger.Error( + "failed to check migration status", + logger.Field{Key: "migration", Value: migName}, + logger.Field{Key: "error", Value: err}, + ) + return fmt.Errorf("failed to check migration status for %s: %w", migName, err) + } + + if !applied { + return nil + } + + s.logger.Info("Rolling back migration", logger.Field{Key: "migration", Value: migName}) + + rollbackSQL := ` + DROP TABLE IF EXISTS file_download_events; + DROP TABLE IF EXISTS file_download_links; + ` + + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("failed to begin file download links rollback: %w", err) + } + defer func() { + _ = tx.Rollback() + }() + + _, err = tx.Exec(rollbackSQL) + if err != nil { + return fmt.Errorf("failed to rollback file download links tables: %w", err) + } + + if _, err := tx.Exec("DELETE FROM migrations WHERE name = ?", migName); err != nil { + s.logger.Error( + "failed to mark migration as rolled back", + logger.Field{Key: "migration", Value: migName}, + logger.Field{Key: "error", Value: err}, + ) + return fmt.Errorf("failed to mark migration as rolled back: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit file download links rollback: %w", err) + } + + return nil +} diff --git a/internal/db/mock_InternalDB.go b/internal/db/mock_InternalDB.go index aa05b1d..b8dfbf0 100644 --- a/internal/db/mock_InternalDB.go +++ b/internal/db/mock_InternalDB.go @@ -705,6 +705,68 @@ func (_c *MockInternalDB_CreateDirectoryShortcut_Call) RunAndReturn(run func(use return _c } +// CreateFileDownloadLink provides a mock function for the type MockInternalDB +func (_mock *MockInternalDB) CreateFileDownloadLink(payload FileDownloadLinkPayload) (*FileDownloadLink, error) { + ret := _mock.Called(payload) + + if len(ret) == 0 { + panic("no return value specified for CreateFileDownloadLink") + } + + var r0 *FileDownloadLink + var r1 error + if returnFunc, ok := ret.Get(0).(func(FileDownloadLinkPayload) (*FileDownloadLink, error)); ok { + return returnFunc(payload) + } + if returnFunc, ok := ret.Get(0).(func(FileDownloadLinkPayload) *FileDownloadLink); ok { + r0 = returnFunc(payload) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*FileDownloadLink) + } + } + if returnFunc, ok := ret.Get(1).(func(FileDownloadLinkPayload) error); ok { + r1 = returnFunc(payload) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockInternalDB_CreateFileDownloadLink_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateFileDownloadLink' +type MockInternalDB_CreateFileDownloadLink_Call struct { + *mock.Call +} + +// CreateFileDownloadLink is a helper method to define mock.On call +// - payload FileDownloadLinkPayload +func (_e *MockInternalDB_Expecter) CreateFileDownloadLink(payload interface{}) *MockInternalDB_CreateFileDownloadLink_Call { + return &MockInternalDB_CreateFileDownloadLink_Call{Call: _e.mock.On("CreateFileDownloadLink", payload)} +} + +func (_c *MockInternalDB_CreateFileDownloadLink_Call) Run(run func(payload FileDownloadLinkPayload)) *MockInternalDB_CreateFileDownloadLink_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 FileDownloadLinkPayload + if args[0] != nil { + arg0 = args[0].(FileDownloadLinkPayload) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockInternalDB_CreateFileDownloadLink_Call) Return(fileDownloadLink *FileDownloadLink, err error) *MockInternalDB_CreateFileDownloadLink_Call { + _c.Call.Return(fileDownloadLink, err) + return _c +} + +func (_c *MockInternalDB_CreateFileDownloadLink_Call) RunAndReturn(run func(payload FileDownloadLinkPayload) (*FileDownloadLink, error)) *MockInternalDB_CreateFileDownloadLink_Call { + _c.Call.Return(run) + return _c +} + // CreateFileRevision provides a mock function for the type MockInternalDB func (_mock *MockInternalDB) CreateFileRevision(tx *goqu.TxDatabase, fileID string, originalPath string, revisionPath string, previousHash string, currentHash string, createdBy int64) (int64, error) { ret := _mock.Called(tx, fileID, originalPath, revisionPath, previousHash, currentHash, createdBy) @@ -3022,6 +3084,68 @@ func (_c *MockInternalDB_GetDirectoryShortcuts_Call) RunAndReturn(run func(userI return _c } +// GetFileDownloadLinkByPublicID provides a mock function for the type MockInternalDB +func (_mock *MockInternalDB) GetFileDownloadLinkByPublicID(publicID string) (*FileDownloadLink, error) { + ret := _mock.Called(publicID) + + if len(ret) == 0 { + panic("no return value specified for GetFileDownloadLinkByPublicID") + } + + var r0 *FileDownloadLink + var r1 error + if returnFunc, ok := ret.Get(0).(func(string) (*FileDownloadLink, error)); ok { + return returnFunc(publicID) + } + if returnFunc, ok := ret.Get(0).(func(string) *FileDownloadLink); ok { + r0 = returnFunc(publicID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*FileDownloadLink) + } + } + if returnFunc, ok := ret.Get(1).(func(string) error); ok { + r1 = returnFunc(publicID) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockInternalDB_GetFileDownloadLinkByPublicID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetFileDownloadLinkByPublicID' +type MockInternalDB_GetFileDownloadLinkByPublicID_Call struct { + *mock.Call +} + +// GetFileDownloadLinkByPublicID is a helper method to define mock.On call +// - publicID string +func (_e *MockInternalDB_Expecter) GetFileDownloadLinkByPublicID(publicID interface{}) *MockInternalDB_GetFileDownloadLinkByPublicID_Call { + return &MockInternalDB_GetFileDownloadLinkByPublicID_Call{Call: _e.mock.On("GetFileDownloadLinkByPublicID", publicID)} +} + +func (_c *MockInternalDB_GetFileDownloadLinkByPublicID_Call) Run(run func(publicID string)) *MockInternalDB_GetFileDownloadLinkByPublicID_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockInternalDB_GetFileDownloadLinkByPublicID_Call) Return(fileDownloadLink *FileDownloadLink, err error) *MockInternalDB_GetFileDownloadLinkByPublicID_Call { + _c.Call.Return(fileDownloadLink, err) + return _c +} + +func (_c *MockInternalDB_GetFileDownloadLinkByPublicID_Call) RunAndReturn(run func(publicID string) (*FileDownloadLink, error)) *MockInternalDB_GetFileDownloadLinkByPublicID_Call { + _c.Call.Return(run) + return _c +} + // GetFileRevision provides a mock function for the type MockInternalDB func (_mock *MockInternalDB) GetFileRevision(revisionID int64) (*FileRevision, error) { ret := _mock.Called(revisionID) @@ -3622,6 +3746,74 @@ func (_c *MockInternalDB_GetMonsterClientDataCount_Call) RunAndReturn(run func() return _c } +// GetReusableFileDownloadLink provides a mock function for the type MockInternalDB +func (_mock *MockInternalDB) GetReusableFileDownloadLink(payload FileDownloadLinkPayload, now time.Time) (*FileDownloadLink, error) { + ret := _mock.Called(payload, now) + + if len(ret) == 0 { + panic("no return value specified for GetReusableFileDownloadLink") + } + + var r0 *FileDownloadLink + var r1 error + if returnFunc, ok := ret.Get(0).(func(FileDownloadLinkPayload, time.Time) (*FileDownloadLink, error)); ok { + return returnFunc(payload, now) + } + if returnFunc, ok := ret.Get(0).(func(FileDownloadLinkPayload, time.Time) *FileDownloadLink); ok { + r0 = returnFunc(payload, now) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*FileDownloadLink) + } + } + if returnFunc, ok := ret.Get(1).(func(FileDownloadLinkPayload, time.Time) error); ok { + r1 = returnFunc(payload, now) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockInternalDB_GetReusableFileDownloadLink_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetReusableFileDownloadLink' +type MockInternalDB_GetReusableFileDownloadLink_Call struct { + *mock.Call +} + +// GetReusableFileDownloadLink is a helper method to define mock.On call +// - payload FileDownloadLinkPayload +// - now time.Time +func (_e *MockInternalDB_Expecter) GetReusableFileDownloadLink(payload interface{}, now interface{}) *MockInternalDB_GetReusableFileDownloadLink_Call { + return &MockInternalDB_GetReusableFileDownloadLink_Call{Call: _e.mock.On("GetReusableFileDownloadLink", payload, now)} +} + +func (_c *MockInternalDB_GetReusableFileDownloadLink_Call) Run(run func(payload FileDownloadLinkPayload, now time.Time)) *MockInternalDB_GetReusableFileDownloadLink_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 FileDownloadLinkPayload + if args[0] != nil { + arg0 = args[0].(FileDownloadLinkPayload) + } + var arg1 time.Time + if args[1] != nil { + arg1 = args[1].(time.Time) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockInternalDB_GetReusableFileDownloadLink_Call) Return(fileDownloadLink *FileDownloadLink, err error) *MockInternalDB_GetReusableFileDownloadLink_Call { + _c.Call.Return(fileDownloadLink, err) + return _c +} + +func (_c *MockInternalDB_GetReusableFileDownloadLink_Call) RunAndReturn(run func(payload FileDownloadLinkPayload, now time.Time) (*FileDownloadLink, error)) *MockInternalDB_GetReusableFileDownloadLink_Call { + _c.Call.Return(run) + return _c +} + // GetRevisionSummary provides a mock function for the type MockInternalDB func (_mock *MockInternalDB) GetRevisionSummary(fileID string) (*RevisionSummary, error) { ret := _mock.Called(fileID) @@ -5428,6 +5620,75 @@ func (_c *MockInternalDB_MigrateUp_Call) RunAndReturn(run func() error) *MockInt return _c } +// RecordFileDownload provides a mock function for the type MockInternalDB +func (_mock *MockInternalDB) RecordFileDownload(link *FileDownloadLink, userID int64, userAgent *string, ipAddress *string) error { + ret := _mock.Called(link, userID, userAgent, ipAddress) + + if len(ret) == 0 { + panic("no return value specified for RecordFileDownload") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(*FileDownloadLink, int64, *string, *string) error); ok { + r0 = returnFunc(link, userID, userAgent, ipAddress) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockInternalDB_RecordFileDownload_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RecordFileDownload' +type MockInternalDB_RecordFileDownload_Call struct { + *mock.Call +} + +// RecordFileDownload is a helper method to define mock.On call +// - link *FileDownloadLink +// - userID int64 +// - userAgent *string +// - ipAddress *string +func (_e *MockInternalDB_Expecter) RecordFileDownload(link interface{}, userID interface{}, userAgent interface{}, ipAddress interface{}) *MockInternalDB_RecordFileDownload_Call { + return &MockInternalDB_RecordFileDownload_Call{Call: _e.mock.On("RecordFileDownload", link, userID, userAgent, ipAddress)} +} + +func (_c *MockInternalDB_RecordFileDownload_Call) Run(run func(link *FileDownloadLink, userID int64, userAgent *string, ipAddress *string)) *MockInternalDB_RecordFileDownload_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 *FileDownloadLink + if args[0] != nil { + arg0 = args[0].(*FileDownloadLink) + } + var arg1 int64 + if args[1] != nil { + arg1 = args[1].(int64) + } + var arg2 *string + if args[2] != nil { + arg2 = args[2].(*string) + } + var arg3 *string + if args[3] != nil { + arg3 = args[3].(*string) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *MockInternalDB_RecordFileDownload_Call) Return(err error) *MockInternalDB_RecordFileDownload_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockInternalDB_RecordFileDownload_Call) RunAndReturn(run func(link *FileDownloadLink, userID int64, userAgent *string, ipAddress *string) error) *MockInternalDB_RecordFileDownload_Call { + _c.Call.Return(run) + return _c +} + // ReorderServerProcesses provides a mock function for the type MockInternalDB func (_mock *MockInternalDB) ReorderServerProcesses(updates []ReorderUpdate) error { ret := _mock.Called(updates) diff --git a/internal/odbc/sqlserverdsn/driver_path_windows.go b/internal/odbc/sqlserverdsn/driver_path_windows.go index 3bd201d..4d169d3 100644 --- a/internal/odbc/sqlserverdsn/driver_path_windows.go +++ b/internal/odbc/sqlserverdsn/driver_path_windows.go @@ -19,7 +19,9 @@ func resolveDriverDLLPath() (string, error) { if err != nil { continue } - defer key.Close() + defer func(k registry.Key) { + _ = k.Close() + }(key) value, _, err := key.GetStringValue("Driver") if err != nil || value == "" { diff --git a/internal/permissions/permissions.go b/internal/permissions/permissions.go index bd33276..1d8954f 100644 --- a/internal/permissions/permissions.go +++ b/internal/permissions/permissions.go @@ -12,6 +12,7 @@ const ( ActionViewFiles PermissionAction = "view_files" ActionEditFiles PermissionAction = "edit_files" ActionRevertFiles PermissionAction = "revert_files" + ActionDownloadFiles PermissionAction = "download_files" ActionUploadGameData PermissionAction = "upload_game_data" ActionManageUsers PermissionAction = "manage_users" ActionViewMetrics PermissionAction = "view_metrics" @@ -24,6 +25,7 @@ var rolePermissions = map[PermissionAction][]string{ ActionViewFiles: {constants.RoleSuperAdmin, constants.RoleAdmin, constants.RoleUser}, ActionEditFiles: {constants.RoleSuperAdmin, constants.RoleAdmin}, ActionRevertFiles: {constants.RoleSuperAdmin, constants.RoleAdmin}, + ActionDownloadFiles: {constants.RoleSuperAdmin, constants.RoleAdmin}, ActionUploadGameData: {constants.RoleSuperAdmin, constants.RoleAdmin}, ActionManageUsers: {constants.RoleSuperAdmin}, ActionViewMetrics: {constants.RoleSuperAdmin, constants.RoleAdmin, constants.RoleUser}, diff --git a/internal/permissions/permissions_test.go b/internal/permissions/permissions_test.go index cfa912b..8a45843 100644 --- a/internal/permissions/permissions_test.go +++ b/internal/permissions/permissions_test.go @@ -67,6 +67,24 @@ func TestIsAllowed(t *testing.T) { roles: []string{constants.RoleUser}, expected: false, }, + { + name: "super_admin can download files", + action: ActionDownloadFiles, + roles: []string{constants.RoleSuperAdmin}, + expected: true, + }, + { + name: "admin can download files", + action: ActionDownloadFiles, + roles: []string{constants.RoleAdmin}, + expected: true, + }, + { + name: "viewer cannot download files", + action: ActionDownloadFiles, + roles: []string{constants.RoleUser}, + expected: false, + }, { name: "super_admin can upload game data", action: ActionUploadGameData, diff --git a/internal/server/backup_routes.go b/internal/server/backup_routes.go index e1d6d3e..4d087a6 100644 --- a/internal/server/backup_routes.go +++ b/internal/server/backup_routes.go @@ -4,8 +4,8 @@ import ( "encoding/json" "errors" "net/http" - "path/filepath" "strconv" + "time" "github.com/go-chi/chi/v5" "github.com/omnihance/omnihance-a3-agent/internal/constants" @@ -35,6 +35,7 @@ func (s *Server) InitializeBackupRoutes(r *chi.Mux) { r.Post("/jobs/{id}/cancel", s.handleCancelBackupJob) r.Get("/jobs/{id}/runs", s.handleListBackupRuns) r.Get("/runs/{run_id}", s.handleGetBackupRun) + r.Post("/runs/{run_id}/files/{file_id}/download-link", s.handleCreateBackupRunFileDownloadLink) r.Get("/runs/{run_id}/files/{file_id}/download", s.handleDownloadBackupRunFile) r.Get("/path-search", s.handleBackupPathSearch) r.Get("/defaults/sql-server", s.handleBackupSQLServerDefaults) @@ -212,17 +213,17 @@ func (s *Server) handleGetBackupRun(w http.ResponseWriter, r *http.Request) { return } - run, err := s.backupService.GetRunDetails(runID) + details, err := s.backupService.GetRunDetails(runID) if err != nil { writeBackupServiceError(w, err) return } - _ = utils.WriteJSONResponse(w, run) + _ = utils.WriteJSONResponse(w, s.backupRunDetailsResponse(details)) } -func (s *Server) handleDownloadBackupRunFile(w http.ResponseWriter, r *http.Request) { - if !s.requireUserPermission(w, r, permissions.ActionManageServer) { +func (s *Server) handleCreateBackupRunFileDownloadLink(w http.ResponseWriter, r *http.Request) { + if !s.requireUserPermission(w, r, permissions.ActionDownloadFiles) { return } @@ -236,30 +237,57 @@ func (s *Server) handleDownloadBackupRunFile(w http.ResponseWriter, r *http.Requ return } - file, err := s.backupService.GetRunFile(fileID) + file, err := s.validatedBackupDownloadFile(runID, fileID) if err != nil { - writeBackupServiceError(w, err) + writeBackupDownloadError(w, err) + return + } + + response, err := s.createDownloadLinkForPath(r, file.FilePath, downloadLinkSource{ + sourceType: db.FileDownloadSourceBackup, + backupRunID: &runID, + backupFileID: &fileID, + }) + if err != nil { + writeDownloadLinkError(w, backupsErrorContext, err) return } - if file.RunID != runID { - writeBackupError(w, http.StatusNotFound, constants.ErrorCodeNotFound, "Backup file not found") + _ = utils.WriteJSONResponse(w, response) +} + +func (s *Server) handleDownloadBackupRunFile(w http.ResponseWriter, r *http.Request) { + if !s.requireUserPermission(w, r, permissions.ActionDownloadFiles) { return } - info, err := s.fileEditor.Stat(file.FilePath) + runID, ok := backupIDParam(w, r, "run_id", "Invalid backup run ID") + if !ok { + return + } + + fileID, ok := backupIDParam(w, r, "file_id", "Invalid backup file ID") + if !ok { + return + } + + file, err := s.validatedBackupDownloadFile(runID, fileID) if err != nil { - writeBackupError(w, http.StatusNotFound, constants.ErrorCodeNotFound, "Backup file is missing") + writeBackupDownloadError(w, err) return } - if info.IsDir() { - writeBackupError(w, http.StatusBadRequest, constants.ErrorCodeBadRequest, "Backup output is a directory") + response, err := s.createDownloadLinkForPath(r, file.FilePath, downloadLinkSource{ + sourceType: db.FileDownloadSourceBackup, + backupRunID: &runID, + backupFileID: &fileID, + }) + if err != nil { + writeDownloadLinkError(w, backupsErrorContext, err) return } - w.Header().Set("Content-Disposition", `attachment; filename="`+filepath.Base(file.FilePath)+`"`) - http.ServeFile(w, r, file.FilePath) + http.Redirect(w, r, response.DownloadURL, http.StatusFound) } func (s *Server) handleBackupPathSearch(w http.ResponseWriter, r *http.Request) { @@ -309,6 +337,82 @@ func writeBackupError(w http.ResponseWriter, status int, errorCode string, messa }) } +func writeBackupDownloadError(w http.ResponseWriter, err error) { + var downloadErr *downloadLinkError + if errors.As(err, &downloadErr) { + writeBackupError(w, downloadErr.status, downloadErr.errorCode, downloadErr.message) + return + } + + writeBackupServiceError(w, err) +} + +func (s *Server) validatedBackupDownloadFile(runID int64, fileID int64) (*db.BackupRunFile, error) { + details, err := s.backupService.GetRunDetails(runID) + if err != nil { + return nil, err + } + + if details.Run.Status != db.BackupRunStatusSucceeded { + return nil, newDownloadLinkError(http.StatusBadRequest, constants.ErrorCodeBadRequest, "Backup run did not succeed") + } + + for _, file := range details.Files { + if file.ID != fileID { + continue + } + + if file.RunID != runID { + return nil, newDownloadLinkError(http.StatusNotFound, constants.ErrorCodeNotFound, "Backup file not found") + } + + info, err := s.fileEditor.Stat(file.FilePath) + if err != nil { + return nil, newDownloadLinkError(http.StatusNotFound, constants.ErrorCodeNotFound, "Backup file is missing") + } + + if info.IsDir() { + return nil, newDownloadLinkError(http.StatusBadRequest, constants.ErrorCodeBadRequest, "Backup output is a directory") + } + + selectedFile := file + return &selectedFile, nil + } + + return nil, newDownloadLinkError(http.StatusNotFound, constants.ErrorCodeNotFound, "Backup file not found") +} + +func (s *Server) backupRunDetailsResponse(details *services.BackupRunDetails) BackupRunDetailsResponse { + files := make([]BackupRunFileResponse, len(details.Files)) + for i, file := range details.Files { + files[i] = s.backupRunFileResponse(details.Run, file) + } + + return BackupRunDetailsResponse{ + Run: details.Run, + Files: files, + } +} + +func (s *Server) backupRunFileResponse(run db.BackupRun, file db.BackupRunFile) BackupRunFileResponse { + downloadAvailable := false + if run.Status == db.BackupRunStatusSucceeded { + if info, err := s.fileEditor.Stat(file.FilePath); err == nil && !info.IsDir() { + downloadAvailable = true + } + } + + return BackupRunFileResponse{ + ID: file.ID, + RunID: file.RunID, + ItemName: file.ItemName, + FilePath: file.FilePath, + FileSize: file.FileSize, + CreatedAt: file.CreatedAt, + DownloadAvailable: downloadAvailable, + } +} + func backupIDParam(w http.ResponseWriter, r *http.Request, name string, message string) (int64, bool) { id, err := strconv.ParseInt(chi.URLParam(r, name), 10, 64) if err != nil { @@ -357,6 +461,21 @@ type BackupRunsResponse struct { Pagination PaginationInfo `json:"pagination"` } +type BackupRunDetailsResponse struct { + Run db.BackupRun `json:"run"` + Files []BackupRunFileResponse `json:"files"` +} + +type BackupRunFileResponse struct { + ID int64 `json:"id"` + RunID int64 `json:"run_id"` + ItemName string `json:"item_name"` + FilePath string `json:"file_path"` + FileSize int64 `json:"file_size"` + CreatedAt time.Time `json:"created_at"` + DownloadAvailable bool `json:"download_available"` +} + type BackupJobRequest struct { JobType string `json:"job_type"` Name string `json:"name"` diff --git a/internal/server/backup_routes_test.go b/internal/server/backup_routes_test.go new file mode 100644 index 0000000..4e459cb --- /dev/null +++ b/internal/server/backup_routes_test.go @@ -0,0 +1,195 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/omnihance/omnihance-a3-agent/internal/config" + "github.com/omnihance/omnihance-a3-agent/internal/constants" + "github.com/omnihance/omnihance-a3-agent/internal/db" + "github.com/omnihance/omnihance-a3-agent/internal/services" + "github.com/stretchr/testify/require" +) + +func TestCreateBackupRunFileDownloadLinkUsesSharedDownloadLink(t *testing.T) { + server, backupService, fixture := newBackupDownloadTestServer(t, db.BackupRunStatusSucceeded, true) + backupService.EXPECT().GetRunDetails(fixture.runID).Return(backupDownloadRunDetails(fixture, db.BackupRunStatusSucceeded), nil) + + req := backupRunFileRequest(http.MethodPost, "/download-link", constants.RoleAdmin, fixture) + rr := httptest.NewRecorder() + + server.handleCreateBackupRunFileDownloadLink(rr, req) + + require.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) + var response DownloadLinkResponse + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &response)) + require.True(t, strings.HasPrefix(response.DownloadURL, "/api/file-tree/download/")) + + publicID, _, err := server.verifyDownloadToken(strings.TrimPrefix(response.DownloadURL, "/api/file-tree/download/")) + require.NoError(t, err) + link, err := server.internalDB.GetFileDownloadLinkByPublicID(publicID) + require.NoError(t, err) + require.Equal(t, db.FileDownloadSourceBackup, link.SourceType) + require.Equal(t, fixture.runID, *link.BackupRunID) + require.Equal(t, fixture.fileID, *link.BackupFileID) +} + +func TestCreateBackupRunFileDownloadLinkRejectsFailedRunAndWrongFile(t *testing.T) { + server, backupService, fixture := newBackupDownloadTestServer(t, db.BackupRunStatusFailed, true) + backupService.EXPECT().GetRunDetails(fixture.runID).Return(backupDownloadRunDetails(fixture, db.BackupRunStatusFailed), nil) + + req := backupRunFileRequest(http.MethodPost, "/download-link", constants.RoleAdmin, fixture) + rr := httptest.NewRecorder() + server.handleCreateBackupRunFileDownloadLink(rr, req) + require.Equal(t, http.StatusBadRequest, rr.Code) + + server, backupService, fixture = newBackupDownloadTestServer(t, db.BackupRunStatusRunning, true) + backupService.EXPECT().GetRunDetails(fixture.runID).Return(backupDownloadRunDetails(fixture, db.BackupRunStatusRunning), nil) + + req = backupRunFileRequest(http.MethodPost, "/download-link", constants.RoleAdmin, fixture) + rr = httptest.NewRecorder() + server.handleCreateBackupRunFileDownloadLink(rr, req) + require.Equal(t, http.StatusBadRequest, rr.Code) + + server, backupService, fixture = newBackupDownloadTestServer(t, db.BackupRunStatusSucceeded, true) + backupService.EXPECT().GetRunDetails(fixture.runID).Return(&services.BackupRunDetails{ + Run: db.BackupRun{ID: fixture.runID, Status: db.BackupRunStatusSucceeded}, + Files: []db.BackupRunFile{{ + ID: fixture.fileID, + RunID: fixture.runID + 1, + ItemName: "server", + FilePath: fixture.filePath, + FileSize: 4, + }}, + }, nil) + + req = backupRunFileRequest(http.MethodPost, "/download-link", constants.RoleAdmin, fixture) + rr = httptest.NewRecorder() + server.handleCreateBackupRunFileDownloadLink(rr, req) + require.Equal(t, http.StatusNotFound, rr.Code) +} + +func TestGetBackupRunMarksDownloadAvailability(t *testing.T) { + server, backupService, fixture := newBackupDownloadTestServer(t, db.BackupRunStatusSucceeded, true) + missingPath := filepath.Join(t.TempDir(), "missing.zip") + backupService.EXPECT().GetRunDetails(fixture.runID).Return(&services.BackupRunDetails{ + Run: db.BackupRun{ID: fixture.runID, Status: db.BackupRunStatusSucceeded}, + Files: []db.BackupRunFile{ + {ID: fixture.fileID, RunID: fixture.runID, ItemName: "available", FilePath: fixture.filePath, FileSize: 4, CreatedAt: time.Now()}, + {ID: fixture.fileID + 1, RunID: fixture.runID, ItemName: "missing", FilePath: missingPath, FileSize: 4, CreatedAt: time.Now()}, + }, + }, nil) + + req := backupDownloadRequest(http.MethodGet, "/api/backups/runs/"+strconv.FormatInt(fixture.runID, 10), constants.RoleAdmin) + req = withURLParam(req, "run_id", strconv.FormatInt(fixture.runID, 10)) + rr := httptest.NewRecorder() + server.handleGetBackupRun(rr, req) + + require.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) + var response BackupRunDetailsResponse + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &response)) + require.True(t, response.Files[0].DownloadAvailable) + require.False(t, response.Files[1].DownloadAvailable) +} + +func TestOldBackupDownloadRouteRedirectsThroughTempLink(t *testing.T) { + server, backupService, fixture := newBackupDownloadTestServer(t, db.BackupRunStatusSucceeded, true) + backupService.EXPECT().GetRunDetails(fixture.runID).Return(backupDownloadRunDetails(fixture, db.BackupRunStatusSucceeded), nil) + + req := backupRunFileRequest(http.MethodGet, "/download", constants.RoleAdmin, fixture) + rr := httptest.NewRecorder() + server.handleDownloadBackupRunFile(rr, req) + + require.Equal(t, http.StatusFound, rr.Code, rr.Body.String()) + require.True(t, strings.HasPrefix(rr.Header().Get("Location"), "/api/file-tree/download/")) +} + +func newBackupDownloadTestServer(t *testing.T, status string, createFile bool) (*Server, *services.MockBackupService, backupDownloadFixture) { + t.Helper() + + internalDB := newTestInternalDB(t) + _, err := internalDB.CreateUser("backup-download-admin@example.com", "password", constants.RoleAdmin, nil) + require.NoError(t, err) + backupService := services.NewMockBackupService(t) + dir := t.TempDir() + filePath := filepath.Join(dir, "backup.zip") + if createFile { + require.NoError(t, os.WriteFile(filePath, []byte("data"), 0600)) + } + + sourcePath := filePath + job, err := internalDB.CreateBackupJob(db.BackupJobPayload{ + JobType: db.BackupJobTypeFile, + Name: "Backup", + Status: db.BackupJobStatusActive, + DestinationDirectory: dir, + SourcePath: &sourcePath, + }, nil) + require.NoError(t, err) + run, err := internalDB.CreateBackupRun(job.ID, db.BackupRunTriggerManual, db.BackupJobStatusActive, nil) + require.NoError(t, err) + require.NoError(t, internalDB.FinishBackupRun(run.ID, job.ID, status, db.BackupJobStatusActive, nil, nil)) + file, err := internalDB.CreateBackupRunFile(run.ID, "server", filePath, 4) + require.NoError(t, err) + + return &Server{ + cfg: &config.EnvVars{CookieSecret: "test-secret"}, + internalDB: internalDB, + fileEditor: services.NewFileEditorService(nil), + backupService: backupService, + }, backupService, backupDownloadFixture{ + runID: run.ID, + fileID: file.ID, + filePath: filePath, + } +} + +func backupDownloadRunDetails(fixture backupDownloadFixture, status string) *services.BackupRunDetails { + return &services.BackupRunDetails{ + Run: db.BackupRun{ID: fixture.runID, Status: status}, + Files: []db.BackupRunFile{{ + ID: fixture.fileID, + RunID: fixture.runID, + ItemName: "server", + FilePath: fixture.filePath, + FileSize: 4, + }}, + } +} + +func backupRunFileRequest(method string, suffix string, role string, fixture backupDownloadFixture) *http.Request { + runID := strconv.FormatInt(fixture.runID, 10) + fileID := strconv.FormatInt(fixture.fileID, 10) + req := backupDownloadRequest(method, "/api/backups/runs/"+runID+"/files/"+fileID+suffix, role) + return withBackupRunFileParams(req, runID, fileID) +} + +func backupDownloadRequest(method string, target string, role string) *http.Request { + return downloadRequest(method, target, nil, role, 1) +} + +func withBackupRunFileParams(req *http.Request, runID string, fileID string) *http.Request { + routeContext := chi.NewRouteContext() + routeContext.URLParams.Add("run_id", runID) + routeContext.URLParams.Add("file_id", fileID) + return req.WithContext(contextWithChiRoute(req, routeContext)) +} + +func contextWithChiRoute(req *http.Request, routeContext *chi.Context) context.Context { + return context.WithValue(req.Context(), chi.RouteCtxKey, routeContext) +} + +type backupDownloadFixture struct { + runID int64 + fileID int64 + filePath string +} diff --git a/internal/server/file_download_routes.go b/internal/server/file_download_routes.go new file mode 100644 index 0000000..7185f25 --- /dev/null +++ b/internal/server/file_download_routes.go @@ -0,0 +1,411 @@ +package server + +import ( + "crypto/hmac" + "crypto/md5" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "io" + "mime" + "net" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/omnihance/omnihance-a3-agent/internal/constants" + "github.com/omnihance/omnihance-a3-agent/internal/db" + "github.com/omnihance/omnihance-a3-agent/internal/permissions" + "github.com/omnihance/omnihance-a3-agent/internal/utils" +) + +const ( + fileDownloadLinkLifetime = 24 * time.Hour + fileDownloadErrorContext = "file-system" +) + +func (s *Server) handleCreateFileDownloadLink(w http.ResponseWriter, r *http.Request) { + if !s.requireUserPermission(w, r, permissions.ActionDownloadFiles) { + return + } + + pathParam := r.URL.Query().Get("path") + if pathParam == "" { + writeFileDownloadError(w, http.StatusBadRequest, constants.ErrorCodeBadRequest, fileDownloadErrorContext, "Path parameter is required") + return + } + + response, err := s.createDownloadLinkForPath(r, filepath.Clean(pathParam), downloadLinkSource{ + sourceType: db.FileDownloadSourceFileBrowser, + }) + if err != nil { + writeDownloadLinkError(w, fileDownloadErrorContext, err) + return + } + + _ = utils.WriteJSONResponse(w, response) +} + +func (s *Server) handleDownloadLinkedFile(w http.ResponseWriter, r *http.Request) { + if !s.requireUserPermission(w, r, permissions.ActionDownloadFiles) { + return + } + + userID, ok := utils.GetUserIdFromContext(r.Context()) + if !ok { + writeFileDownloadError(w, http.StatusUnauthorized, constants.ErrorCodeUnauthorized, fileDownloadErrorContext, "User not found in context") + return + } + + token := chi.URLParam(r, "token") + publicID, tokenExpiresAt, err := s.verifyDownloadToken(token) + if err != nil { + writeFileDownloadError(w, http.StatusForbidden, constants.ErrorCodeForbidden, fileDownloadErrorContext, "Download link is invalid") + return + } + + now := time.Now() + if !tokenExpiresAt.After(now) { + writeFileDownloadError(w, http.StatusGone, constants.ErrorCodeBadRequest, fileDownloadErrorContext, "Download link has expired") + return + } + + link, err := s.internalDB.GetFileDownloadLinkByPublicID(publicID) + if err != nil { + if !errors.Is(err, constants.ErrNotFound) { + writeFileDownloadError(w, http.StatusInternalServerError, constants.ErrorCodeInternalServerError, fileDownloadErrorContext, "Failed to load download link") + return + } + + writeFileDownloadError(w, http.StatusNotFound, constants.ErrorCodeNotFound, fileDownloadErrorContext, "Download link not found") + return + } + + if link.UserID != userID { + writeFileDownloadError(w, http.StatusForbidden, constants.ErrorCodeForbidden, fileDownloadErrorContext, "Download link belongs to another user") + return + } + + if link.ExpiresAt.Unix() != tokenExpiresAt.Unix() || !link.ExpiresAt.After(now) { + writeFileDownloadError(w, http.StatusGone, constants.ErrorCodeBadRequest, fileDownloadErrorContext, "Download link has expired") + return + } + + file, fingerprint, err := s.openFileDownload(link.OriginalPath) + if err != nil { + writeDownloadLinkError(w, fileDownloadErrorContext, err) + return + } + defer func() { + _ = file.Close() + }() + + if !downloadFingerprintMatchesLink(fingerprint, link) { + writeFileDownloadError(w, http.StatusGone, constants.ErrorCodeBadRequest, fileDownloadErrorContext, "Download link is no longer valid because the file changed") + return + } + + userAgent := optionalString(r.UserAgent()) + ipAddress := optionalString(downloadRequestIP(r)) + if err := s.internalDB.RecordFileDownload(link, userID, userAgent, ipAddress); err != nil { + writeFileDownloadError(w, http.StatusInternalServerError, constants.ErrorCodeInternalServerError, fileDownloadErrorContext, "Failed to record file download") + return + } + + contentDisposition := mime.FormatMediaType("attachment", map[string]string{"filename": link.FileName}) + if contentDisposition == "" { + contentDisposition = `attachment; filename="` + filepath.Base(link.FileName) + `"` + } + + w.Header().Set("Content-Disposition", contentDisposition) + http.ServeContent(w, r, link.FileName, fingerprint.modTime, file) +} + +func (s *Server) createDownloadLinkForPath(r *http.Request, path string, source downloadLinkSource) (*DownloadLinkResponse, error) { + userID, ok := utils.GetUserIdFromContext(r.Context()) + if !ok { + return nil, newDownloadLinkError(http.StatusUnauthorized, constants.ErrorCodeUnauthorized, "User not found in context") + } + + fingerprint, err := s.buildFileDownloadFingerprint(path) + if err != nil { + return nil, err + } + + now := time.Now() + payload := db.FileDownloadLinkPayload{ + UserID: userID, + FileID: fingerprint.fileID, + SourceType: source.sourceType, + BackupRunID: source.backupRunID, + BackupFileID: source.backupFileID, + OriginalPath: fingerprint.path, + FileName: fingerprint.fileName, + FileSize: fingerprint.fileSize, + FileHash: fingerprint.fileHash, + FileModifiedAt: fingerprint.fileModifiedAt, + } + + link, err := s.internalDB.GetReusableFileDownloadLink(payload, now) + if err != nil { + return nil, newDownloadLinkError(http.StatusInternalServerError, constants.ErrorCodeInternalServerError, "Failed to get download link") + } + + reused := link != nil + if link == nil { + payload.PublicID = uuid.NewString() + payload.ExpiresAt = now.Add(fileDownloadLinkLifetime) + link, err = s.internalDB.CreateFileDownloadLink(payload) + if err != nil { + return nil, newDownloadLinkError(http.StatusInternalServerError, constants.ErrorCodeInternalServerError, "Failed to create download link") + } + } + + downloadURL, err := s.downloadURLForLink(link) + if err != nil { + return nil, newDownloadLinkError(http.StatusInternalServerError, constants.ErrorCodeInternalServerError, "Failed to create download URL") + } + + return &DownloadLinkResponse{ + DownloadURL: downloadURL, + ExpiresAt: link.ExpiresAt, + Reused: reused, + DownloadCount: link.DownloadCount, + }, nil +} + +func (s *Server) buildFileDownloadFingerprint(path string) (*downloadFingerprint, error) { + file, fingerprint, err := s.openFileDownload(path) + if err != nil { + return nil, err + } + defer func() { + _ = file.Close() + }() + + return fingerprint, nil +} + +func (s *Server) openFileDownload(path string) (*os.File, *downloadFingerprint, error) { + cleanPath := filepath.Clean(path) + info, err := s.fileEditor.Stat(cleanPath) + if err != nil { + if s.fileEditor.IsNotExist(err) { + return nil, nil, newDownloadLinkError(http.StatusNotFound, constants.ErrorCodeNotFound, "Path not found") + } + + return nil, nil, newDownloadLinkError(http.StatusInternalServerError, constants.ErrorCodeFileReadError, "Cannot read file: "+err.Error()) + } + + if info.IsDir() { + return nil, nil, newDownloadLinkError(http.StatusBadRequest, constants.ErrorCodePathIsDirectory, "Path is a directory, not a file") + } + + file, err := s.fileEditor.OpenFile(cleanPath, os.O_RDONLY, 0) + if err != nil { + if s.fileEditor.IsNotExist(err) { + return nil, nil, newDownloadLinkError(http.StatusNotFound, constants.ErrorCodeNotFound, "Path not found") + } + + return nil, nil, newDownloadLinkError(http.StatusInternalServerError, constants.ErrorCodeFileReadError, "Cannot read file: "+err.Error()) + } + + success := false + defer func() { + if !success { + _ = file.Close() + } + }() + + info, err = file.Stat() + if err != nil { + return nil, nil, newDownloadLinkError(http.StatusInternalServerError, constants.ErrorCodeFileReadError, "Cannot read file: "+err.Error()) + } + + if info.IsDir() { + return nil, nil, newDownloadLinkError(http.StatusBadRequest, constants.ErrorCodePathIsDirectory, "Path is a directory, not a file") + } + + fileHash, err := hashFile(file) + if err != nil { + return nil, nil, newDownloadLinkError(http.StatusInternalServerError, constants.ErrorCodeFileReadError, "Failed to hash file: "+err.Error()) + } + + if _, err := file.Seek(0, io.SeekStart); err != nil { + return nil, nil, newDownloadLinkError(http.StatusInternalServerError, constants.ErrorCodeFileReadError, "Failed to prepare file download: "+err.Error()) + } + + success = true + return file, &downloadFingerprint{ + path: cleanPath, + fileID: utils.GenerateMD5Hash(cleanPath), + fileName: filepath.Base(cleanPath), + fileSize: info.Size(), + fileHash: fileHash, + fileModifiedAt: info.ModTime().UnixNano(), + modTime: info.ModTime(), + }, nil +} + +func hashFile(file *os.File) (string, error) { + hash := md5.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + + return hex.EncodeToString(hash.Sum(nil)), nil +} + +func (s *Server) downloadURLForLink(link *db.FileDownloadLink) (string, error) { + token, err := s.signDownloadToken(link.PublicID, link.ExpiresAt) + if err != nil { + return "", err + } + + return "/api/file-tree/download/" + token, nil +} + +func (s *Server) signDownloadToken(publicID string, expiresAt time.Time) (string, error) { + secret, err := s.downloadTokenSecret() + if err != nil { + return "", err + } + + expiresUnix := strconv.FormatInt(expiresAt.Unix(), 10) + message := publicID + "." + expiresUnix + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(message)) + signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + return message + "." + signature, nil +} + +func (s *Server) verifyDownloadToken(token string) (string, time.Time, error) { + secret, err := s.downloadTokenSecret() + if err != nil { + return "", time.Time{}, err + } + + parts := strings.Split(token, ".") + if len(parts) != 3 { + return "", time.Time{}, errors.New("invalid token shape") + } + + expiresUnix, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil { + return "", time.Time{}, err + } + + message := parts[0] + "." + parts[1] + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(message)) + expected := mac.Sum(nil) + actual, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return "", time.Time{}, err + } + + if !hmac.Equal(actual, expected) { + return "", time.Time{}, errors.New("invalid token signature") + } + + return parts[0], time.Unix(expiresUnix, 0), nil +} + +func (s *Server) downloadTokenSecret() (string, error) { + if s.cfg == nil || strings.TrimSpace(s.cfg.CookieSecret) == "" { + return "", errors.New("download token secret is not configured") + } + + return s.cfg.CookieSecret, nil +} + +func downloadFingerprintMatchesLink(fingerprint *downloadFingerprint, link *db.FileDownloadLink) bool { + return fingerprint.fileID == link.FileID && + fingerprint.path == link.OriginalPath && + fingerprint.fileSize == link.FileSize && + fingerprint.fileHash == link.FileHash && + fingerprint.fileModifiedAt == link.FileModifiedAt +} + +func writeDownloadLinkError(w http.ResponseWriter, context string, err error) { + var downloadErr *downloadLinkError + if errors.As(err, &downloadErr) { + writeFileDownloadError(w, downloadErr.status, downloadErr.errorCode, context, downloadErr.message) + return + } + + writeFileDownloadError(w, http.StatusInternalServerError, constants.ErrorCodeInternalServerError, context, err.Error()) +} + +func writeFileDownloadError(w http.ResponseWriter, status int, errorCode string, context string, message string) { + _ = utils.WriteJSONResponseWithStatus(w, status, map[string]interface{}{ + "errorCode": errorCode, + "context": context, + "errors": []string{message}, + }) +} + +func newDownloadLinkError(status int, errorCode string, message string) error { + return &downloadLinkError{ + status: status, + errorCode: errorCode, + message: message, + } +} + +func downloadRequestIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil { + return host + } + + return r.RemoteAddr +} + +func optionalString(value string) *string { + if strings.TrimSpace(value) == "" { + return nil + } + + return &value +} + +type DownloadLinkResponse struct { + DownloadURL string `json:"download_url"` + ExpiresAt time.Time `json:"expires_at"` + Reused bool `json:"reused"` + DownloadCount int64 `json:"download_count"` +} + +type downloadLinkSource struct { + sourceType string + backupRunID *int64 + backupFileID *int64 +} + +type downloadFingerprint struct { + path string + fileID string + fileName string + fileSize int64 + fileHash string + fileModifiedAt int64 + modTime time.Time +} + +type downloadLinkError struct { + status int + errorCode string + message string +} + +func (e *downloadLinkError) Error() string { + return fmt.Sprintf("%s: %s", e.errorCode, e.message) +} diff --git a/internal/server/file_download_routes_test.go b/internal/server/file_download_routes_test.go new file mode 100644 index 0000000..d18daa1 --- /dev/null +++ b/internal/server/file_download_routes_test.go @@ -0,0 +1,180 @@ +package server + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/omnihance/omnihance-a3-agent/internal/config" + "github.com/omnihance/omnihance-a3-agent/internal/constants" + "github.com/omnihance/omnihance-a3-agent/internal/db" + "github.com/omnihance/omnihance-a3-agent/internal/services" + "github.com/omnihance/omnihance-a3-agent/internal/utils" + "github.com/stretchr/testify/require" +) + +func TestCreateFileDownloadLinkReusesUnchangedFile(t *testing.T) { + server := newFileDownloadTestServer(t) + filePath := writeDownloadTestFile(t, t.TempDir(), "server.txt", []byte("server data")) + + first := createFileDownloadLinkForTest(t, server, filePath, constants.RoleAdmin, 1) + require.False(t, first.Reused) + require.Equal(t, int64(0), first.DownloadCount) + require.True(t, strings.HasPrefix(first.DownloadURL, "/api/file-tree/download/")) + + second := createFileDownloadLinkForTest(t, server, filePath, constants.RoleAdmin, 1) + require.True(t, second.Reused) + require.Equal(t, first.DownloadURL, second.DownloadURL) +} + +func TestCreateFileDownloadLinkRejectsViewerAndDirectory(t *testing.T) { + server := newFileDownloadTestServer(t) + filePath := writeDownloadTestFile(t, t.TempDir(), "server.txt", []byte("server data")) + + req := downloadRequest(http.MethodPost, "/api/file-tree/download-link?path="+url.QueryEscape(filePath), nil, constants.RoleUser, 1) + rr := httptest.NewRecorder() + server.handleCreateFileDownloadLink(rr, req) + require.Equal(t, http.StatusForbidden, rr.Code) + + req = downloadRequest(http.MethodPost, "/api/file-tree/download-link?path="+url.QueryEscape(t.TempDir()), nil, constants.RoleAdmin, 1) + rr = httptest.NewRecorder() + server.handleCreateFileDownloadLink(rr, req) + require.Equal(t, http.StatusBadRequest, rr.Code) +} + +func TestDownloadLinkedFileStreamsAttachmentAndTracksCount(t *testing.T) { + server := newFileDownloadTestServer(t) + fileContent := []byte("download body") + filePath := writeDownloadTestFile(t, t.TempDir(), "server.txt", fileContent) + link := createFileDownloadLinkForTest(t, server, filePath, constants.RoleAdmin, 1) + + rr := downloadLinkedFileForTest(t, server, link.DownloadURL, constants.RoleAdmin, 1) + + require.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) + require.Equal(t, fileContent, rr.Body.Bytes()) + require.Contains(t, rr.Header().Get("Content-Disposition"), "attachment") + require.Contains(t, rr.Header().Get("Content-Disposition"), "server.txt") + + publicID, _, err := server.verifyDownloadToken(strings.TrimPrefix(link.DownloadURL, "/api/file-tree/download/")) + require.NoError(t, err) + updatedLink, err := server.internalDB.GetFileDownloadLinkByPublicID(publicID) + require.NoError(t, err) + require.Equal(t, int64(1), updatedLink.DownloadCount) +} + +func TestDownloadLinkedFileRejectsWrongUserExpiredChangedAndMissingFile(t *testing.T) { + server := newFileDownloadTestServer(t) + filePath := writeDownloadTestFile(t, t.TempDir(), "server.txt", []byte("server data")) + link := createFileDownloadLinkForTest(t, server, filePath, constants.RoleAdmin, 1) + + rr := downloadLinkedFileForTest(t, server, link.DownloadURL, constants.RoleAdmin, 2) + require.Equal(t, http.StatusForbidden, rr.Code) + + require.NoError(t, os.WriteFile(filePath, []byte("changed data"), 0600)) + rr = downloadLinkedFileForTest(t, server, link.DownloadURL, constants.RoleAdmin, 1) + require.Equal(t, http.StatusGone, rr.Code) + + missingLink := createFileDownloadLinkForTest(t, server, filePath, constants.RoleAdmin, 1) + require.NoError(t, os.Remove(filePath)) + rr = downloadLinkedFileForTest(t, server, missingLink.DownloadURL, constants.RoleAdmin, 1) + require.Equal(t, http.StatusNotFound, rr.Code) + + expiredPath := writeDownloadTestFile(t, t.TempDir(), "expired.txt", []byte("expired data")) + expiredLink := createExpiredDownloadLinkForTest(t, server, expiredPath) + rr = downloadLinkedFileForTest(t, server, expiredLink, constants.RoleAdmin, 1) + require.Equal(t, http.StatusGone, rr.Code) +} + +func newFileDownloadTestServer(t *testing.T) *Server { + t.Helper() + + internalDB := newTestInternalDB(t) + _, err := internalDB.CreateUser("download-admin@example.com", "password", constants.RoleAdmin, nil) + require.NoError(t, err) + _, err = internalDB.CreateUser("download-admin-2@example.com", "password", constants.RoleAdmin, nil) + require.NoError(t, err) + + return &Server{ + cfg: &config.EnvVars{CookieSecret: "test-secret"}, + internalDB: internalDB, + fileEditor: services.NewFileEditorService(nil), + } +} + +func createFileDownloadLinkForTest(t *testing.T, server *Server, filePath string, role string, userID int64) DownloadLinkResponse { + t.Helper() + + req := downloadRequest(http.MethodPost, "/api/file-tree/download-link?path="+url.QueryEscape(filePath), nil, role, userID) + rr := httptest.NewRecorder() + server.handleCreateFileDownloadLink(rr, req) + require.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) + + var response DownloadLinkResponse + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &response)) + return response +} + +func createExpiredDownloadLinkForTest(t *testing.T, server *Server, filePath string) string { + t.Helper() + + fingerprint, err := server.buildFileDownloadFingerprint(filePath) + require.NoError(t, err) + link, err := server.internalDB.CreateFileDownloadLink(db.FileDownloadLinkPayload{ + PublicID: "expired-link", + UserID: 1, + FileID: fingerprint.fileID, + SourceType: db.FileDownloadSourceFileBrowser, + OriginalPath: fingerprint.path, + FileName: fingerprint.fileName, + FileSize: fingerprint.fileSize, + FileHash: fingerprint.fileHash, + FileModifiedAt: fingerprint.fileModifiedAt, + ExpiresAt: time.Now().Add(-time.Hour), + }) + require.NoError(t, err) + + downloadURL, err := server.downloadURLForLink(link) + require.NoError(t, err) + return downloadURL +} + +func downloadLinkedFileForTest(t *testing.T, server *Server, downloadURL string, role string, userID int64) *httptest.ResponseRecorder { + t.Helper() + + token := strings.TrimPrefix(downloadURL, "/api/file-tree/download/") + req := downloadRequest(http.MethodGet, downloadURL, nil, role, userID) + req = withURLParam(req, "token", token) + rr := httptest.NewRecorder() + server.handleDownloadLinkedFile(rr, req) + return rr +} + +func downloadRequest(method string, target string, body *bytes.Buffer, role string, userID int64) *http.Request { + var reader *strings.Reader + if body == nil { + reader = strings.NewReader("") + } else { + reader = strings.NewReader(body.String()) + } + + req := httptest.NewRequest(method, target, reader) + ctx := req.Context() + ctx = utils.SetUserIdInContext(ctx, userID) + ctx = utils.SetUserRolesInContext(ctx, []string{role}) + return req.WithContext(ctx) +} + +func writeDownloadTestFile(t *testing.T, dir string, name string, content []byte) string { + t.Helper() + + path := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(path, content, 0600)) + return path +} diff --git a/internal/server/file_system_routes.go b/internal/server/file_system_routes.go index 8916ea4..1d72f4a 100644 --- a/internal/server/file_system_routes.go +++ b/internal/server/file_system_routes.go @@ -61,6 +61,8 @@ func (s *Server) InitializeFileSystemRoutes(r *chi.Mux) { r.Post("/revert-file", s.handleRevertFile) r.Post("/duplicate-file", s.handleDuplicateFile) r.Get("/revision-summary", s.handleRevisionSummary) + r.Post("/download-link", s.handleCreateFileDownloadLink) + r.Get("/download/{token}", s.handleDownloadLinkedFile) }) } diff --git a/internal/utils/userdsn/dsn_windows.go b/internal/utils/userdsn/dsn_windows.go index b429510..7e83641 100644 --- a/internal/utils/userdsn/dsn_windows.go +++ b/internal/utils/userdsn/dsn_windows.go @@ -42,7 +42,9 @@ func (m *windowsManager) List() ([]string, error) { } return nil, fmt.Errorf("userdsn: open ODBC Data Sources: %w", err) } - defer key.Close() + defer func() { + _ = key.Close() + }() names, err := key.ReadValueNames(-1) if err != nil { @@ -64,7 +66,9 @@ func (m *windowsManager) Get(name string) (*Config, error) { } return nil, fmt.Errorf("userdsn: open ODBC Data Sources: %w", err) } - defer sourcesKey.Close() + defer func() { + _ = sourcesKey.Close() + }() driver, _, err := sourcesKey.GetStringValue(name) if err != nil { @@ -81,7 +85,9 @@ func (m *windowsManager) Get(name string) (*Config, error) { } return nil, fmt.Errorf("userdsn: open DSN key %q: %w", name, err) } - defer dsnKey.Close() + defer func() { + _ = dsnKey.Close() + }() valueNames, err := dsnKey.ReadValueNames(-1) if err != nil { @@ -127,7 +133,9 @@ func (m *windowsManager) Add(cfg Config) error { if err != nil { return fmt.Errorf("userdsn: open ODBC Data Sources: %w", err) } - defer sourcesKey.Close() + defer func() { + _ = sourcesKey.Close() + }() if err := sourcesKey.SetStringValue(cfg.Name, cfg.Driver); err != nil { return fmt.Errorf("userdsn: register DSN name %q: %w", cfg.Name, err) @@ -158,7 +166,9 @@ func (m *windowsManager) Update(cfg Config) error { if err != nil { return fmt.Errorf("userdsn: open ODBC Data Sources: %w", err) } - defer sourcesKey.Close() + defer func() { + _ = sourcesKey.Close() + }() if err := sourcesKey.SetStringValue(cfg.Name, cfg.Driver); err != nil { return fmt.Errorf("userdsn: update driver mapping for %q: %w", cfg.Name, err) @@ -183,7 +193,9 @@ func (m *windowsManager) Delete(name string) error { if err != nil { return fmt.Errorf("userdsn: open ODBC Data Sources: %w", err) } - defer sourcesKey.Close() + defer func() { + _ = sourcesKey.Close() + }() if err := sourcesKey.DeleteValue(name); err != nil { return fmt.Errorf("userdsn: remove DSN entry %q: %w", name, err) @@ -203,7 +215,9 @@ func writeAttrs(cfg Config) error { if err != nil { return fmt.Errorf("userdsn: create DSN key %q: %w", cfg.Name, err) } - defer dsnKey.Close() + defer func() { + _ = dsnKey.Close() + }() valueNames, err := dsnKey.ReadValueNames(-1) if err != nil {