diff --git a/api.go b/api.go
index 1b926e0..52a670a 100644
--- a/api.go
+++ b/api.go
@@ -1,6 +1,9 @@
package main
import (
+ "os"
+ "path/filepath"
+ "sort"
"strings"
"time"
@@ -545,3 +548,63 @@ func (app *appContext) Restart() error {
time.Sleep(time.Second)
return nil
}
+
+// @Summary Creates a backup of the database.
+// @Router /backups [post]
+// @Success 200 {object} CreateBackupDTO
+// @Security Bearer
+// @tags Other
+func (app *appContext) CreateBackup(gc *gin.Context) {
+ backup := app.makeBackup()
+ gc.JSON(200, backup)
+}
+
+// @Summary Download a specific backup file. Requires auth, so can't be accessed plainly in the browser.
+// @Param fname path string true "backup filename"
+// @Router /backups/{fname} [get]
+// @Produce octet-stream
+// @Produce json
+// @Success 200 {body} file
+// @Success 400 {object} boolResponse
+// @Security Bearer
+// @tags Other
+func (app *appContext) GetBackup(gc *gin.Context) {
+ fname := gc.Param("fname")
+ // Hopefully this is enough to ensure the path isn't malicious. Hidden behind bearer auth anyway so shouldn't matter too much I guess.
+ ok := strings.HasPrefix(fname, BACKUP_PREFIX) && strings.HasSuffix(fname, BACKUP_SUFFIX)
+ t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(fname, BACKUP_PREFIX), BACKUP_SUFFIX))
+ if !ok || err != nil || t.IsZero() {
+ app.debug.Printf("Ignoring backup DL request due to fname: %v\n", err)
+ respondBool(400, false, gc)
+ return
+ }
+ path := app.config.Section("backups").Key("path").String()
+ fullpath := filepath.Join(path, fname)
+ gc.FileAttachment(fullpath, fname)
+}
+
+// @Summary Get a list of backups.
+// @Router /backups [get]
+// @Produce json
+// @Success 200 {object} GetBackupsDTO
+// @Security Bearer
+// @tags Other
+func (app *appContext) GetBackups(gc *gin.Context) {
+ path := app.config.Section("backups").Key("path").String()
+ backups := app.getBackups()
+ sort.Sort(backups)
+ resp := GetBackupsDTO{}
+ resp.Backups = make([]CreateBackupDTO, backups.count)
+
+ for i, item := range backups.files[:backups.count] {
+ resp.Backups[i].Name = item.Name()
+ fullpath := filepath.Join(path, item.Name())
+ resp.Backups[i].Path = fullpath
+ resp.Backups[i].Date = backups.dates[i].Unix()
+ fstat, err := os.Stat(fullpath)
+ if err == nil {
+ resp.Backups[i].Size = fileSize(fstat.Size())
+ }
+ }
+ gc.JSON(200, resp)
+}
diff --git a/config.go b/config.go
index ba4373c..91ea6bd 100644
--- a/config.go
+++ b/config.go
@@ -114,6 +114,7 @@ func (app *appContext) loadConfig() error {
app.MustSetValue("backups", "every_n_minutes", "1440")
app.MustSetValue("backups", "path", filepath.Join(app.dataPath, "backups"))
+ app.MustSetValue("backups", "keep_n_backups", "20")
app.config.Section("jellyfin").Key("version").SetValue(version)
app.config.Section("jellyfin").Key("device").SetValue("jfa-go")
diff --git a/daemon.go b/daemon.go
index e541cab..2a2cc8a 100644
--- a/daemon.go
+++ b/daemon.go
@@ -1,6 +1,7 @@
package main
import (
+ "fmt"
"os"
"path/filepath"
"sort"
@@ -87,6 +88,7 @@ func (app *appContext) clearTelegram() {
type BackupList struct {
files []os.DirEntry
dates []time.Time
+ count int
}
func (bl BackupList) Len() int { return len(bl.files) }
@@ -108,24 +110,37 @@ func (bl BackupList) Less(i, j int) bool {
return bl.dates[j].After(bl.dates[i])
}
-func (app *appContext) makeBackup() {
- toKeep := app.config.Section("backups").Key("keep_n_backups").MustInt(20)
- fname := BACKUP_PREFIX + time.Now().Local().Format(BACKUP_DATEFMT) + BACKUP_SUFFIX
+// Get human-readable file size from f.Size() result.
+// https://programming.guide/go/formatting-byte-size-to-human-readable-format.html
+func fileSize(l int64) string {
+ const unit = 1000
+ if l < unit {
+ return fmt.Sprintf("%dB", l)
+ }
+ div, exp := int64(unit), 0
+ for n := l / unit; n >= unit; n /= unit {
+ div *= unit
+ exp++
+ }
+ return fmt.Sprintf("%.1f%c", float64(l)/float64(div), "KMGTPE"[exp])
+}
+
+func (app *appContext) getBackups() *BackupList {
path := app.config.Section("backups").Key("path").String()
err := os.MkdirAll(path, 0755)
if err != nil {
app.err.Printf("Failed to create backup directory \"%s\": %v\n", path, err)
- return
+ return nil
}
items, err := os.ReadDir(path)
if err != nil {
app.err.Printf("Failed to read backup directory \"%s\": %v\n", path, err)
- return
+ return nil
}
- backups := BackupList{}
+ backups := &BackupList{}
backups.files = items
backups.dates = make([]time.Time, len(items))
- backupCount := 0
+ backups.count = 0
for i, item := range items {
if item.IsDir() || !(strings.HasSuffix(item.Name(), BACKUP_SUFFIX)) {
continue
@@ -136,10 +151,22 @@ func (app *appContext) makeBackup() {
continue
}
backups.dates[i] = t
- backupCount++
+ backups.count++
}
- toDelete := backupCount + 1 - toKeep
- if toDelete > 0 {
+ return backups
+}
+
+func (app *appContext) makeBackup() (fileDetails CreateBackupDTO) {
+ toKeep := app.config.Section("backups").Key("keep_n_backups").MustInt(20)
+ fname := BACKUP_PREFIX + time.Now().Local().Format(BACKUP_DATEFMT) + BACKUP_SUFFIX
+ path := app.config.Section("backups").Key("path").String()
+ backups := app.getBackups()
+ if backups == nil {
+ return
+ }
+ toDelete := backups.count + 1 - toKeep
+ // fmt.Printf("toDelete: %d, backCount: %d, keep: %d, length: %d\n", toDelete, backups.count, toKeep, len(backups.files))
+ if toDelete > 0 && toDelete <= backups.count {
sort.Sort(backups)
for _, item := range backups.files[:toDelete] {
fullpath := filepath.Join(path, item.Name())
@@ -163,10 +190,21 @@ func (app *appContext) makeBackup() {
app.err.Printf("Failed to create backup: %v\n", err)
return
}
+
+ fstat, err := f.Stat()
+ if err != nil {
+ app.err.Printf("Failed to get info on new backup: %v\n", err)
+ return
+ }
+ fileDetails.Size = fileSize(fstat.Size())
+ fileDetails.Name = fname
+ fileDetails.Path = fullpath
+ // fmt.Printf("Created backup %+v\n", fileDetails)
+ return
}
func (app *appContext) clearActivities() {
- app.debug.Println("Husekeeping: Cleaning up Activity log...")
+ app.debug.Println("Housekeeping: Cleaning up Activity log...")
keepCount := app.config.Section("activity_log").Key("keep_n_records").MustInt(1000)
maxAgeDays := app.config.Section("activity_log").Key("delete_after_days").MustInt(90)
minAge := time.Now().AddDate(0, 0, -maxAgeDays)
diff --git a/html/admin.html b/html/admin.html
index a035767..cad852b 100644
--- a/html/admin.html
+++ b/html/admin.html
@@ -328,6 +328,40 @@
+
+
+
{{ .strings.backups }} ×
+
{{ .strings.backupsDescription }}
+
{{ .strings.backupsFormatNote }}
+
+
+
+
+
+
+
+
+
+ {{ .strings.name }} |
+ {{ .strings.date }} |
+ {{ .strings.backupDownloadRestore }} |
+
+
+
+
+
+
+
+
+
+
{{ .strings.backupCreated }} ×
+
+
{{ .strings.backupCanDownload }}
+
+
+
+
+
{{ .strings.settingsApplied }}
@@ -810,7 +844,8 @@
- {{ .strings.logs }}
+ {{ .strings.logs }}
+ {{ .strings.backups }}
{{ .strings.settingsRestart }}
{{ .strings.settingsSave }}
diff --git a/lang/admin/en-us.json b/lang/admin/en-us.json
index c1a4c55..d4580fd 100644
--- a/lang/admin/en-us.json
+++ b/lang/admin/en-us.json
@@ -180,9 +180,21 @@
"noMoreResults": "No more results.",
"totalRecords": "{n} Total Records",
"loadedRecords": "{n} Loaded",
- "shownRecords": "{n} Shown"
+ "shownRecords": "{n} Shown",
+ "backups": "Backups",
+ "backupsDescription": "Backups of the database can be made, restored, or downloaded from here.",
+ "backupsFormatNote": "Only backup files with the standard name format will be shown here. To use any other, upload the backup manually.",
+ "backupDownloadRestore": "Download / Restore",
+ "backupUpload": "Upload backup",
+ "backupDownload": "Download backup",
+ "backupRestore": "Restore backup",
+ "backupNow": "Backup Now",
+ "backupCreated": "Backup created",
+ "backupCanBeFound": "The backup can be found on the server at {filepath}.",
+ "backupCanDownload": "Alternatively, click below to download the backup."
},
"notifications": {
+ "pathCopied": "Full path copied to clipboard.",
"changedEmailAddress": "Changed email address of {n}.",
"userCreated": "User {n} created.",
"createProfile": "Created profile {n}.",
diff --git a/models.go b/models.go
index becc66d..ba46f7b 100644
--- a/models.go
+++ b/models.go
@@ -461,3 +461,14 @@ type GetActivitiesRespDTO struct {
type GetActivityCountDTO struct {
Count uint64 `json:"count"`
}
+
+type CreateBackupDTO struct {
+ Size string `json:"size"`
+ Name string `json:"name"`
+ Path string `json:"path"`
+ Date int64 `json:"date"`
+}
+
+type GetBackupsDTO struct {
+ Backups []CreateBackupDTO `json:"backups"`
+}
diff --git a/router.go b/router.go
index 4d1fac4..6c45ec0 100644
--- a/router.go
+++ b/router.go
@@ -208,6 +208,9 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
api.POST(p+"/config", app.ModifyConfig)
api.POST(p+"/restart", app.restart)
api.GET(p+"/logs", app.GetLog)
+ api.POST(p+"/backups", app.CreateBackup)
+ api.GET(p+"/backups/:fname", app.GetBackup)
+ api.GET(p+"/backups", app.GetBackups)
if telegramEnabled || discordEnabled || matrixEnabled {
api.GET(p+"/telegram/pin", app.TelegramGetPin)
api.GET(p+"/telegram/verified/:pin", app.TelegramVerified)
diff --git a/ts/admin.ts b/ts/admin.ts
index 5edce5b..b827f2f 100644
--- a/ts/admin.ts
+++ b/ts/admin.ts
@@ -68,6 +68,10 @@ window.availableProfiles = window.availableProfiles || [];
window.modals.logs = new Modal(document.getElementById("modal-logs"));
+ window.modals.backedUp = new Modal(document.getElementById("modal-backed-up"));
+
+ window.modals.backups = new Modal(document.getElementById("modal-backups"));
+
if (window.telegramEnabled) {
window.modals.telegram = new Modal(document.getElementById("modal-telegram"));
}
diff --git a/ts/modules/common.ts b/ts/modules/common.ts
index 063253f..9de70fd 100644
--- a/ts/modules/common.ts
+++ b/ts/modules/common.ts
@@ -40,6 +40,22 @@ export const _get = (url: string, data: Object, onreadystatechange: (req: XMLHtt
req.send(JSON.stringify(data));
};
+export const _download = (url: string, fname: string): void => {
+ let req = new XMLHttpRequest();
+ if (window.URLBase) { url = window.URLBase + url; }
+ req.open("GET", url, true);
+ req.responseType = 'blob';
+ req.setRequestHeader("Authorization", "Bearer " + window.token);
+ req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
+ req.onload = (e: Event) => {
+ let link = document.createElement("a") as HTMLAnchorElement;
+ link.href = URL.createObjectURL(req.response);
+ link.download = fname;
+ link.dispatchEvent(new MouseEvent("click"));
+ };
+ req.send();
+};
+
export const _post = (url: string, data: Object, onreadystatechange: (req: XMLHttpRequest) => void, response?: boolean, statusHandler?: (req: XMLHttpRequest) => void): void => {
let req = new XMLHttpRequest();
req.open("POST", window.URLBase + url, true);
diff --git a/ts/modules/settings.ts b/ts/modules/settings.ts
index 045d3a3..66b6bb2 100644
--- a/ts/modules/settings.ts
+++ b/ts/modules/settings.ts
@@ -1,7 +1,14 @@
-import { _get, _post, _delete, toggleLoader, addLoader, removeLoader, insertText } from "../modules/common.js";
+import { _get, _post, _delete, _download, toggleLoader, addLoader, removeLoader, insertText, toClipboard, toDateString } from "../modules/common.js";
import { Marked } from "@ts-stack/markdown";
import { stripMarkdown } from "../modules/stripmd.js";
+interface BackupDTO {
+ size: string;
+ name: string;
+ path: string;
+ date: number;
+}
+
interface settingsBoolEvent extends Event {
detail: boolean;
}
@@ -635,6 +642,8 @@ export class settingsList {
private _noResultsPanel: HTMLElement = document.getElementById("settings-not-found");
+ private _backupSortDirection = document.getElementById("settings-backups-sort-direction") as HTMLButtonElement;
+ private _backupSortAscending = true;
addSection = (name: string, s: Section, subButton?: HTMLElement) => {
const section = new sectionPanel(s, name);
@@ -736,6 +745,59 @@ export class settingsList {
}
});
+ setBackupSort = (ascending: boolean) => {
+ this._backupSortAscending = ascending;
+ this._backupSortDirection.innerHTML = `${window.lang.strings("sortDirection")}
`;
+ this._getBackups();
+ };
+
+ private _backup = () => _post("/backups", null, (req: XMLHttpRequest) => {
+ if (req.readyState != 4 || req.status != 200) return;
+ const backupDTO = req.response as BackupDTO;
+ if (backupDTO.path == "") {
+ window.notifications.customError("backupError", window.lang.strings("errorFailureCheckLogs"));
+ return;
+ }
+ const location = document.getElementById("settings-backed-up-location");
+ const download = document.getElementById("settings-backed-up-download");
+ location.innerHTML = window.lang.strings("backupCanBeFound").replace("{filepath}", `
"`+backupDTO.path+`"`);
+ download.innerHTML = `
+
+
${window.lang.strings("download")}
+
${backupDTO.size}
+ `;
+
+ download.parentElement.onclick = () => _download("/backups/" + backupDTO.name, backupDTO.name);
+ window.modals.backedUp.show();
+ }, true);
+
+ private _getBackups = () => _get("/backups", null, (req: XMLHttpRequest) => {
+ if (req.readyState != 4 || req.status != 200) return;
+ const backups = req.response["backups"] as BackupDTO[];
+ const table = document.getElementById("backups-list");
+ table.textContent = ``;
+ if (!this._backupSortAscending) {
+ backups.reverse();
+ }
+ for (let b of backups) {
+ const tr = document.createElement("tr") as HTMLTableRowElement;
+ tr.innerHTML = `
+
${b.name} |
+
${toDateString(new Date(b.date*1000))} |
+
+
+
+ |
+ `;
+ tr.querySelector(".backup-copy").addEventListener("click", () => {
+ toClipboard(b.path);
+ window.notifications.customPositive("pathCopied", "", window.lang.notif("pathCopied"));
+ });
+ tr.querySelector(".backup-download").addEventListener("click", () => _download("/backups/" + b.name, b.name));
+ table.appendChild(tr);
+ }
+ });
+
constructor() {
this._sections = {};
this._buttons = {};
@@ -748,6 +810,16 @@ export class settingsList {
this._saveButton.onclick = this._save;
document.addEventListener("settings-requires-restart", () => { this._needsRestart = true; });
document.getElementById("settings-logs").onclick = this._showLogs;
+ document.getElementById("settings-backups-backup").onclick = () => {
+ window.modals.backups.close();
+ this._backup();
+ };
+
+ document.getElementById("settings-backups").onclick = () => {
+ this.setBackupSort(this._backupSortAscending);
+ window.modals.backups.show();
+ };
+ this._backupSortDirection.onclick = () => this.setBackupSort(!(this._backupSortAscending));
const advancedEnableToggle = document.getElementById("settings-advanced-enabled") as HTMLInputElement;
advancedEnableToggle.onchange = () => {
document.dispatchEvent(new CustomEvent("settings-advancedState", { detail: advancedEnableToggle.checked }));
diff --git a/ts/typings/d.ts b/ts/typings/d.ts
index 0270412..2055038 100644
--- a/ts/typings/d.ts
+++ b/ts/typings/d.ts
@@ -117,6 +117,8 @@ declare interface Modals {
email?: Modal;
enableReferralsUser?: Modal;
enableReferralsProfile?: Modal;
+ backedUp?: Modal;
+ backups?: Modal;
}
interface Invite {