backups: triggerable in ui, viewable, downloadable

new "Backups" menu in settings lists all available backups, lets you
trigger a new one, and lets you download them.
This commit is contained in:
Harvey Tindall 2023-12-21 16:47:17 +00:00
parent 733ab37539
commit 195813c058
Signed by: hrfee
GPG Key ID: BBC65952848FB1A2
11 changed files with 271 additions and 14 deletions

63
api.go
View File

@ -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)
}

View File

@ -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")

View File

@ -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)

View File

@ -328,6 +328,40 @@
</div>
</div>
</div>
<div id="modal-backups" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading">{{ .strings.backups }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.backupsDescription }}</p>
<p class="content my-4">{{ .strings.backupsFormatNote }}</p>
<div class="row col flex">
<button class="button ~info @low mr-2 mt-4 mb-4" id="settings-backups-backup">{{ .strings.backupNow }}</button>
<button class="button ~neutral @low mr-2 mt-4 mb-4" id="settings-backups-upload">{{ .strings.backupUpload }}</button>
<button class="button ~neutral @low mr-2 mt-4 mb-4" id="settings-backups-sort-direction">{{ .strings.sortDirection }}</button>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{{ .strings.name }}</th>
<th>{{ .strings.date }}</th>
<th class="table-inline justify-center">{{ .strings.backupDownloadRestore }}</th>
</tr>
</thead>
<tbody id="backups-list"></tbody>
</table>
</div>
</div>
</div>
<div id="modal-backed-up" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
<span class="heading">{{ .strings.backupCreated }} <span class="modal-close">&times;</span></span>
<p class="content my-4" id="settings-backed-up-location"></p>
<p class="content my-4">{{ .strings.backupCanDownload }}</p>
<div>
<button class="button flex w-100 ~info @low mb-2"><span class="flex items-center" id="settings-backed-up-download">{{ .strings.download }}</span></button>
</div>
</div>
</div>
<div id="modal-refresh" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
<span class="heading">{{ .strings.settingsApplied }}</span>
@ -810,7 +844,8 @@
</label>
</div>
<div>
<span class="button ~info @low my-1" id="settings-logs">{{ .strings.logs }}</span>
<span class="button ~neutral @low my-1" id="settings-logs">{{ .strings.logs }}</span>
<span class="button ~info @low my-1" id="settings-backups">{{ .strings.backups }}</span>
<span class="button ~neutral @low my-1" id="settings-restart">{{ .strings.settingsRestart }}</span>
<span class="button ~urge @low unfocused my-1" id="settings-save">{{ .strings.settingsSave }}</span>
</div>

View File

@ -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}.",

View File

@ -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"`
}

View File

@ -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)

View File

@ -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"));
}

View File

@ -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);

View File

@ -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")} <i class="ri-arrow-${ascending ? "up" : "down"}-s-line ml-2"></i>`;
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}", `<span class="text-black dark:text-white font-mono bg-inherit">"`+backupDTO.path+`"</span>`);
download.innerHTML = `
<i class="ri-download-line"></i>
<span class="ml-2">${window.lang.strings("download")}</span>
<span class="badge ~info @low ml-2">${backupDTO.size}</span>
`;
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 = `
<td class="whitespace-nowrap"><span class="text-black dark:text-white font-mono bg-inherit">${b.name}</span> <span class="button ~info @low ml-2 backup-copy" title="${window.lang.strings("copy")}"><i class="ri-file-copy-line"></i></span></td>
<td>${toDateString(new Date(b.date*1000))}</td>
<td class="table-inline justify-center">
<span class="backup-download button ~positive @low" title="${window.lang.strings("backupDownload")}"><i class="ri-download-line"></i></span>
<span class="backup-restore button ~critical @low ml-2 py-[inherit]" title="${window.lang.strings("backupRestore")}"><i class="icon ri-restart-line"></i></span>
</td>
`;
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 }));

View File

@ -117,6 +117,8 @@ declare interface Modals {
email?: Modal;
enableReferralsUser?: Modal;
enableReferralsProfile?: Modal;
backedUp?: Modal;
backups?: Modal;
}
interface Invite {