mirror of
https://github.com/hrfee/jfa-go.git
synced 2024-12-22 09:00:10 +00:00
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:
parent
733ab37539
commit
195813c058
63
api.go
63
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)
|
||||
}
|
||||
|
@ -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")
|
||||
|
60
daemon.go
60
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)
|
||||
|
@ -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">×</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">×</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>
|
||||
|
@ -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}.",
|
||||
|
11
models.go
11
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"`
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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"));
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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 }));
|
||||
|
@ -117,6 +117,8 @@ declare interface Modals {
|
||||
email?: Modal;
|
||||
enableReferralsUser?: Modal;
|
||||
enableReferralsProfile?: Modal;
|
||||
backedUp?: Modal;
|
||||
backups?: Modal;
|
||||
}
|
||||
|
||||
interface Invite {
|
||||
|
Loading…
Reference in New Issue
Block a user