Skip to content

Commit 2523a69

Browse files
FEATURE (backups): Add download backup button
1 parent a6d629e commit 2523a69

File tree

6 files changed

+140
-8
lines changed

6 files changed

+140
-8
lines changed

backend/internal/features/backups/backups/controller.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package backups
22

33
import (
4+
"fmt"
5+
"io"
46
"net/http"
57
"postgresus-backend/internal/features/users"
68

@@ -16,6 +18,7 @@ type BackupController struct {
1618
func (c *BackupController) RegisterRoutes(router *gin.RouterGroup) {
1719
router.GET("/backups", c.GetBackups)
1820
router.POST("/backups", c.MakeBackup)
21+
router.GET("/backups/:id/file", c.GetFile)
1922
router.DELETE("/backups/:id", c.DeleteBackup)
2023
}
2124

@@ -140,6 +143,62 @@ func (c *BackupController) DeleteBackup(ctx *gin.Context) {
140143
ctx.Status(http.StatusNoContent)
141144
}
142145

146+
// GetFile
147+
// @Summary Download a backup file
148+
// @Description Download the backup file for the specified backup
149+
// @Tags backups
150+
// @Param id path string true "Backup ID"
151+
// @Success 200 {file} file
152+
// @Failure 400
153+
// @Failure 401
154+
// @Failure 500
155+
// @Router /backups/{id}/file [get]
156+
func (c *BackupController) GetFile(ctx *gin.Context) {
157+
id, err := uuid.Parse(ctx.Param("id"))
158+
if err != nil {
159+
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid backup ID"})
160+
return
161+
}
162+
163+
authorizationHeader := ctx.GetHeader("Authorization")
164+
if authorizationHeader == "" {
165+
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"})
166+
return
167+
}
168+
169+
user, err := c.userService.GetUserFromToken(authorizationHeader)
170+
if err != nil {
171+
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
172+
return
173+
}
174+
175+
fileReader, err := c.backupService.GetBackupFile(user, id)
176+
if err != nil {
177+
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
178+
return
179+
}
180+
defer func() {
181+
if err := fileReader.Close(); err != nil {
182+
// Log the error but don't interrupt the response
183+
fmt.Printf("Error closing file reader: %v\n", err)
184+
}
185+
}()
186+
187+
// Set headers for file download
188+
ctx.Header("Content-Type", "application/octet-stream")
189+
ctx.Header(
190+
"Content-Disposition",
191+
fmt.Sprintf("attachment; filename=\"backup_%s.dump\"", id.String()),
192+
)
193+
194+
// Stream the file content
195+
_, err = io.Copy(ctx.Writer, fileReader)
196+
if err != nil {
197+
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to stream file"})
198+
return
199+
}
200+
}
201+
143202
type MakeBackupRequest struct {
144203
DatabaseID uuid.UUID `json:"database_id" binding:"required"`
145204
}

backend/internal/features/backups/backups/service.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package backups
33
import (
44
"errors"
55
"fmt"
6+
"io"
67
"log/slog"
78
backups_config "postgresus-backend/internal/features/backups/config"
89
"postgresus-backend/internal/features/databases"
@@ -318,6 +319,22 @@ func (s *BackupService) GetBackup(backupID uuid.UUID) (*Backup, error) {
318319
return s.backupRepository.FindByID(backupID)
319320
}
320321

322+
func (s *BackupService) GetBackupFile(
323+
user *users_models.User,
324+
backupID uuid.UUID,
325+
) (io.ReadCloser, error) {
326+
backup, err := s.backupRepository.FindByID(backupID)
327+
if err != nil {
328+
return nil, err
329+
}
330+
331+
if backup.Database.UserID != user.ID {
332+
return nil, errors.New("user does not have access to this backup")
333+
}
334+
335+
return backup.Storage.GetFile(backup.ID)
336+
}
337+
321338
func (s *BackupService) deleteBackup(backup *Backup) error {
322339
for _, listener := range s.backupRemoveListeners {
323340
if err := listener.OnBeforeBackupRemove(backup); err != nil {

contribute/README.md

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,6 @@ If you need to add some explanation, do it in appropriate place in the code. Or
4747

4848
Before taking anything more than a couple of lines of code, please write Rostislav via Telegram (@rostislav_dugin) and confirm priority. It is possible that we already have something in the works, it is not needed or it's not project priority.
4949

50-
Deploy flow:
51-
52-
- add support of Kubernetes Helm (in progress by Rostislav Dugin)
53-
- add devcontainers for backend and frontend
54-
5550
Backups flow:
5651

5752
- add FTP
@@ -70,14 +65,14 @@ Notifications flow:
7065

7166
Extra:
7267

68+
- add linters and formatters on each PR (in progress by Rostislav Dugin)
7369
- add prettier labels to GitHub README
74-
- allow to download backup file (via streaming)
75-
- add linters and formatters on each PR
7670
- add versioning instead of :latest
7771
- create pretty website like rybbit.io with demo
7872
- add HTTPS for Postgresus
7973
- add simple SQL queries via UI
8074
- add brute force protection on auth (via local RPS limiter)
75+
- add support of Kubernetes Helm
8176

8277
Monitoring flow:
8378

frontend/src/entity/backups/api/backupsApi.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,8 @@ export const backupsApi = {
2424
async deleteBackup(id: string) {
2525
return apiHelper.fetchDeleteRaw(`${getApplicationServer()}/api/v1/backups/${id}`);
2626
},
27+
28+
async downloadBackup(id: string): Promise<Blob> {
29+
return apiHelper.fetchGetBlob(`${getApplicationServer()}/api/v1/backups/${id}/file`);
30+
},
2731
};

frontend/src/features/backups/ui/BackupsComponent.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
CheckCircleOutlined,
33
CloudUploadOutlined,
44
DeleteOutlined,
5+
DownloadOutlined,
56
ExclamationCircleOutlined,
67
InfoCircleOutlined,
78
SyncOutlined,
@@ -39,6 +40,36 @@ export const BackupsComponent = ({ database }: Props) => {
3940

4041
const isReloadInProgress = useRef(false);
4142

43+
const [downloadingBackupId, setDownloadingBackupId] = useState<string | undefined>();
44+
45+
const downloadBackup = async (backupId: string) => {
46+
try {
47+
const blob = await backupsApi.downloadBackup(backupId);
48+
49+
// Create a download link
50+
const url = window.URL.createObjectURL(blob);
51+
const link = document.createElement('a');
52+
link.href = url;
53+
54+
// Find the backup to get a meaningful filename
55+
const backup = backups.find((b) => b.id === backupId);
56+
const createdAt = backup ? dayjs(backup.createdAt).format('YYYY-MM-DD_HH-mm-ss') : 'backup';
57+
link.download = `${database.name}_backup_${createdAt}.dump`;
58+
59+
// Trigger download
60+
document.body.appendChild(link);
61+
link.click();
62+
63+
// Cleanup
64+
document.body.removeChild(link);
65+
window.URL.revokeObjectURL(url);
66+
} catch (e) {
67+
alert((e as Error).message);
68+
} finally {
69+
setDownloadingBackupId(undefined);
70+
}
71+
};
72+
4273
const loadBackups = async () => {
4374
if (isReloadInProgress.current) {
4475
return;
@@ -114,6 +145,12 @@ export const BackupsComponent = ({ database }: Props) => {
114145
return () => clearInterval(interval);
115146
}, [database]);
116147

148+
useEffect(() => {
149+
if (downloadingBackupId) {
150+
downloadBackup(downloadingBackupId);
151+
}
152+
}, [downloadingBackupId]);
153+
117154
const columns: ColumnsType<Backup> = [
118155
{
119156
title: 'Created at',
@@ -272,6 +309,27 @@ export const BackupsComponent = ({ database }: Props) => {
272309
}}
273310
/>
274311
</Tooltip>
312+
313+
<Tooltip
314+
className="ml-3"
315+
title="Download backup file. It can be restored manually via pg_restore (from custom format)"
316+
>
317+
{downloadingBackupId === record.id ? (
318+
<SyncOutlined spin />
319+
) : (
320+
<DownloadOutlined
321+
className="cursor-pointer"
322+
onClick={() => {
323+
if (downloadingBackupId) return;
324+
setDownloadingBackupId(record.id);
325+
}}
326+
style={{
327+
opacity: downloadingBackupId ? 0.2 : 1,
328+
color: '#0d6efd',
329+
}}
330+
/>
331+
)}
332+
</Tooltip>
275333
</>
276334
)}
277335
</div>

frontend/src/shared/api/apiHelper.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,6 @@ export const apiHelper = {
164164
isRetryOnError = false,
165165
): Promise<Blob> => {
166166
const optionsWrapper = (requestOptions ?? new RequestOptions())
167-
.addHeader('Content-Type', 'application/json')
168167
.addHeader('Access-Control-Allow-Methods', 'GET')
169168
.addHeader('Authorization', accessTokenHelper.getAccessToken());
170169

0 commit comments

Comments
 (0)