Optimize multi-target backup distribution

This commit is contained in:
Stamatis Diamantis committed 2026-06-11 06:51:23 +03:00
1 parent 39632a7373
commit 50a1faecd4
3 files changed
+445 -107

No files matched your search

+22 -1
View File
@@ -32,6 +32,22 @@ You can also create the file manually:
"settle_seconds": 15,
"queue_for_browser_drive": true
},
"backup_targets": {
"local": {
"enabled": false,
"path": "/data/garlic/backups"
},
"usb": {
"enabled": true,
"path": "/mnt/usb0/garlic-backups"
},
"ftp": {
"enabled": true
},
"google_drive": {
"enabled": false
}
},
"google_drive": {
"mode": "browser",
"folder_id": "drive_folder_id_shared_with_service_account",
@@ -54,12 +70,16 @@ You can also create the file manually:
The Browse tab exposes Cloud Backup actions for a selected save and for all saves under a title. Backups are encrypted/restorable ZIP archives. PS5 archives contain the raw save image; PS4 archives include the `sdimg_...` image and its `.bin` key companion when present.
Supported providers:
Supported destinations:
- `local`: writes encrypted/restorable ZIP archives on the PS5, defaulting to `/data/garlic/backups`.
- `usb`: writes encrypted/restorable ZIP archives to a connected USB drive, defaulting to `/mnt/usb0/garlic-backups`.
- `ftp`: uploads from the PS5 to a passive plain FTP server.
- `google_drive` with `"mode": "browser"`: the browser fetches the backup ZIP from the PS5 and uploads it to Google Drive. Use service-account auth for Shared Drives, or Browser OAuth Token auth for normal personal My Drive folders.
- `google_drive` with `"mode": "direct"`: configurable, but disabled in this payload build because the PS5 SDK image exposes `libSceSsl.so` without public SSL headers for a safe HTTPS implementation.
The legacy top-level `provider` field still works, but `backup_targets` can enable multiple destinations at once. Manual and auto title backups package one encrypted/restorable ZIP under `/data/garlic/tmp`, then distribute that same ZIP to every enabled PS5-side destination (`local`, `usb`, `ftp`). Google Drive browser mode remains a browser/queue destination.
For Google Drive browser mode, the Drive folder ID is required. Folder names like `ps5_saves` are not accepted as IDs. Service accounts do not have storage quota in normal My Drive folders, so they can only upload to Shared Drives or Workspace delegated drives. For a personal My Drive folder, select Browser OAuth Token in Settings and paste a Drive-scoped access token. Settings -> Test Drive Access verifies the selected auth can see and upload to the folder.
When `auto_backup.enabled` is true, Garlic SaveMgr starts a watcher thread. It probes the PS5 LNC symbols for the running Big App title, snapshots watched save mtimes/sizes when a title starts, then waits `settle_seconds` after the title closes before backing up changed saves. If the LNC probe is unavailable, it falls back to mtime polling for watched titles.
@@ -67,6 +87,7 @@ When `auto_backup.enabled` is true, Garlic SaveMgr starts a watcher thread. It p
Auto upload behavior:
- FTP uploads immediately from the PS5 using the same encrypted/restorable title ZIP as Cloud Backup All.
- Local and USB targets write immediately from the PS5 using the same encrypted/restorable title ZIP as Cloud Backup All. Files are written as `.tmp` first, then renamed to `.zip` when complete.
- Google Drive browser mode writes pending ZIPs to `/data/garlic/cloud_queue/`. The Settings page polls the queue and uploads pending backups when the browser is open with valid Drive auth.
- Google Drive direct mode remains unsupported, but can still queue browser uploads when `queue_for_browser_drive` is true.
+377 -103
View File
@@ -74,6 +74,9 @@
#define MAX_PATH_LEN 1024
#define CLOUD_CONFIG_PATH "/data/garlic/cloud.json"
#define CLOUD_QUEUE_DIR "/data/garlic/cloud_queue"
#define BACKUP_TMP_DIR "/data/garlic/tmp"
#define BACKUP_DEFAULT_LOCAL_DIR "/data/garlic/backups"
#define BACKUP_DEFAULT_USB_DIR "/mnt/usb0/garlic-backups"
#define CLOUD_MAX_TITLES 64
/* Mutex for state-mutating operations (mount/unmount/scan) */
@@ -281,6 +284,18 @@
int queue_for_browser_drive;
} cloud_auto_config_t;
typedef struct {
int enabled;
char path[MAX_PATH_LEN];
} cloud_file_target_t;
typedef struct {
cloud_file_target_t local;
cloud_file_target_t usb;
int ftp_enabled;
int drive_enabled;
} cloud_targets_config_t;
typedef struct {
int enabled;
char provider[32];
@@ -289,6 +304,7 @@
cloud_ftp_config_t ftp;
cloud_drive_config_t drive;
cloud_auto_config_t auto_backup;
cloud_targets_config_t targets;
} cloud_config_t;
typedef struct {
@@ -409,6 +425,27 @@
json_get_string(json, "access_token", drive->access_token, sizeof(drive->access_token));
}
static void cloud_read_backup_targets(const char *json, cloud_config_t *cfg) {
const char *targets = json_find_key(json, "backup_targets");
if (!targets || *targets != '{') {
cfg->targets.ftp_enabled = (strcmp(cfg->provider, "ftp") == 0);
cfg->targets.drive_enabled = (strcmp(cfg->provider, "google_drive") == 0);
return;
}
cfg->targets.local.enabled = json_get_object_bool_default(targets, "local", "enabled", cfg->targets.local.enabled);
if (json_get_object_string(targets, "local", "path", cfg->targets.local.path, sizeof(cfg->targets.local.path)) != 0 &&
json_get_object_string(targets, "local", "folder", cfg->targets.local.path, sizeof(cfg->targets.local.path)) != 0) {
/* Keep default local path. */
}
cfg->targets.usb.enabled = json_get_object_bool_default(targets, "usb", "enabled", cfg->targets.usb.enabled);
if (json_get_object_string(targets, "usb", "path", cfg->targets.usb.path, sizeof(cfg->targets.usb.path)) != 0 &&
json_get_object_string(targets, "usb", "folder", cfg->targets.usb.path, sizeof(cfg->targets.usb.path)) != 0) {
/* Keep default USB path. */
}
cfg->targets.ftp_enabled = json_get_object_bool_default(targets, "ftp", "enabled", cfg->targets.ftp_enabled);
cfg->targets.drive_enabled = json_get_object_bool_default(targets, "google_drive", "enabled", cfg->targets.drive_enabled);
}
static int json_get_watch_titles(const char *json, cloud_config_t *cfg) {
const char *p = json_find_key(json, "watch_titles");
if (!p || *p != '[') return 0;
@@ -496,6 +533,8 @@
cfg->auto_backup.poll_seconds = 5;
cfg->auto_backup.settle_seconds = 15;
cfg->auto_backup.queue_for_browser_drive = 1;
snprintf(cfg->targets.local.path, sizeof(cfg->targets.local.path), BACKUP_DEFAULT_LOCAL_DIR);
snprintf(cfg->targets.usb.path, sizeof(cfg->targets.usb.path), BACKUP_DEFAULT_USB_DIR);
char *json = NULL;
int rr = read_file_limited(CLOUD_CONFIG_PATH, &json, 64 * 1024);
@@ -506,9 +545,12 @@
cfg->enabled = json_get_bool_default(json, "enabled", 0);
json_get_string(json, "provider", cfg->provider, sizeof(cfg->provider));
cfg->targets.ftp_enabled = (strcmp(cfg->provider, "ftp") == 0);
cfg->targets.drive_enabled = (strcmp(cfg->provider, "google_drive") == 0);
json_get_watch_titles(json, cfg);
cloud_read_ftp_fields(json, &cfg->ftp);
cloud_read_drive_fields(json, &cfg->drive);
cloud_read_backup_targets(json, cfg);
json_get_object_string(json, "service_account", "type", cfg->drive.service_account.type, sizeof(cfg->drive.service_account.type));
json_get_object_string(json, "service_account", "project_id", cfg->drive.service_account.project_id, sizeof(cfg->drive.service_account.project_id));
json_get_object_string(json, "service_account", "private_key_id", cfg->drive.service_account.private_key_id, sizeof(cfg->drive.service_account.private_key_id));
@@ -533,7 +575,7 @@
if (!cfg->enabled) return 0;
if (strcmp(cfg->provider, "google_drive") != 0)
snprintf(cfg->provider, sizeof(cfg->provider), "ftp");
if (strcmp(cfg->provider, "ftp") == 0) {
if (cfg->targets.ftp_enabled) {
if (cfg->ftp.use_tls) {
snprintf(err, err_sz, "FTP TLS is not supported in Phase 1");
return -2;
@@ -546,12 +588,17 @@
snprintf(err, err_sz, "Invalid FTP port");
return -4;
}
} else {
}
if (cfg->targets.drive_enabled || strcmp(cfg->provider, "google_drive") == 0) {
if (strcmp(cfg->drive.mode, "direct") != 0)
snprintf(cfg->drive.mode, sizeof(cfg->drive.mode), "browser");
}
if (!cfg->ftp.remote_path[0])
snprintf(cfg->ftp.remote_path, sizeof(cfg->ftp.remote_path), "/");
if (!cfg->targets.local.path[0])
snprintf(cfg->targets.local.path, sizeof(cfg->targets.local.path), BACKUP_DEFAULT_LOCAL_DIR);
if (!cfg->targets.usb.path[0])
snprintf(cfg->targets.usb.path, sizeof(cfg->targets.usb.path), BACKUP_DEFAULT_USB_DIR);
if (cfg->auto_backup.poll_seconds < 2) cfg->auto_backup.poll_seconds = 2;
if (cfg->auto_backup.settle_seconds < 3) cfg->auto_backup.settle_seconds = 3;
return 0;
@@ -567,6 +614,7 @@
static int cloud_config_to_json(const cloud_config_t *cfg, char *out, size_t out_sz, int redact) {
char provider[64], ftp_host[512], ftp_user[256], ftp_pass[256], ftp_path[1024];
char drive_mode[64], auth_mode[64], folder_id[512], client_id[512], client_secret[512], refresh_token[1024], access_token[4096];
char local_path[2048], usb_path[2048];
char sa_type[80], sa_project[256], sa_key_id[256], sa_key[8192], sa_email[512], sa_client_id[512];
char sa_auth_uri[512], sa_token_uri[512], sa_auth_cert[512], sa_client_cert[1024], sa_universe[256];
json_escape(cfg->provider, provider, sizeof(provider));
@@ -584,6 +632,8 @@
refresh_token, sizeof(refresh_token));
json_escape(redact && cfg->drive.access_token[0] ? "********" : cfg->drive.access_token,
access_token, sizeof(access_token));
json_escape(cfg->targets.local.path, local_path, sizeof(local_path));
json_escape(cfg->targets.usb.path, usb_path, sizeof(usb_path));
json_escape(cfg->drive.service_account.type, sa_type, sizeof(sa_type));
json_escape(cfg->drive.service_account.project_id, sa_project, sizeof(sa_project));
json_escape(cfg->drive.service_account.private_key_id, sa_key_id, sizeof(sa_key_id));
@@ -626,6 +676,12 @@
" \"settle_seconds\": %d,\n"
" \"queue_for_browser_drive\": %s\n"
" },\n"
" \"backup_targets\": {\n"
" \"local\": { \"enabled\": %s, \"path\": \"%s\" },\n"
" \"usb\": { \"enabled\": %s, \"path\": \"%s\" },\n"
" \"ftp\": { \"enabled\": %s },\n"
" \"google_drive\": { \"enabled\": %s }\n"
" },\n"
" \"google_drive\": {\n"
" \"mode\": \"%s\",\n"
" \"auth_mode\": \"%s\",\n"
@@ -656,6 +712,10 @@
cfg->auto_backup.poll_seconds,
cfg->auto_backup.settle_seconds,
cfg->auto_backup.queue_for_browser_drive ? "true" : "false",
cfg->targets.local.enabled ? "true" : "false", local_path,
cfg->targets.usb.enabled ? "true" : "false", usb_path,
cfg->targets.ftp_enabled ? "true" : "false",
cfg->targets.drive_enabled ? "true" : "false",
drive_mode, auth_mode, folder_id, client_id, client_secret, refresh_token, access_token,
sa_type, sa_project, sa_key_id, sa_key, sa_email, sa_client_id,
sa_auth_uri, sa_token_uri, sa_auth_cert, sa_client_cert, sa_universe);
@@ -689,12 +749,17 @@
cfg->auto_backup.poll_seconds = 5;
cfg->auto_backup.settle_seconds = 15;
cfg->auto_backup.queue_for_browser_drive = 1;
snprintf(cfg->targets.local.path, sizeof(cfg->targets.local.path), BACKUP_DEFAULT_LOCAL_DIR);
snprintf(cfg->targets.usb.path, sizeof(cfg->targets.usb.path), BACKUP_DEFAULT_USB_DIR);
cfg->enabled = json_get_bool_default(json, "enabled", 0);
json_get_string(json, "provider", cfg->provider, sizeof(cfg->provider));
cfg->targets.ftp_enabled = (strcmp(cfg->provider, "ftp") == 0);
cfg->targets.drive_enabled = (strcmp(cfg->provider, "google_drive") == 0);
json_get_watch_titles(json, cfg);
cloud_read_ftp_fields(json, &cfg->ftp);
cloud_read_drive_fields(json, &cfg->drive);
cloud_read_backup_targets(json, cfg);
json_get_object_string(json, "service_account", "type", cfg->drive.service_account.type, sizeof(cfg->drive.service_account.type));
json_get_object_string(json, "service_account", "project_id", cfg->drive.service_account.project_id, sizeof(cfg->drive.service_account.project_id));
json_get_object_string(json, "service_account", "private_key_id", cfg->drive.service_account.private_key_id, sizeof(cfg->drive.service_account.private_key_id));
@@ -719,12 +784,16 @@
snprintf(cfg->provider, sizeof(cfg->provider), "ftp");
if (strcmp(cfg->drive.mode, "direct") != 0)
snprintf(cfg->drive.mode, sizeof(cfg->drive.mode), "browser");
if (cfg->ftp.port <= 0 || cfg->ftp.port > 65535) {
if (cfg->targets.ftp_enabled && (cfg->ftp.port <= 0 || cfg->ftp.port > 65535)) {
snprintf(err, err_sz, "Invalid FTP port");
return -1;
}
if (!cfg->ftp.remote_path[0])
snprintf(cfg->ftp.remote_path, sizeof(cfg->ftp.remote_path), "/");
if (!cfg->targets.local.path[0])
snprintf(cfg->targets.local.path, sizeof(cfg->targets.local.path), BACKUP_DEFAULT_LOCAL_DIR);
if (!cfg->targets.usb.path[0])
snprintf(cfg->targets.usb.path, sizeof(cfg->targets.usb.path), BACKUP_DEFAULT_USB_DIR);
if (cfg->auto_backup.poll_seconds < 2) cfg->auto_backup.poll_seconds = 2;
if (cfg->auto_backup.settle_seconds < 3) cfg->auto_backup.settle_seconds = 3;
return 0;
@@ -771,6 +840,16 @@
pthread_mutex_unlock(&g_cloud_mtx);
}
static void cloud_set_step(int progress, const char *active, const char *remote) {
pthread_mutex_lock(&g_cloud_mtx);
g_cloud_state.progress = progress;
if (active && active[0])
snprintf(g_cloud_state.active, sizeof(g_cloud_state.active), "%s", active);
if (remote && remote[0])
snprintf(g_cloud_state.last_remote, sizeof(g_cloud_state.last_remote), "%s", remote);
pthread_mutex_unlock(&g_cloud_mtx);
}
static void cloud_set_done(int ok, const char *message, const char *remote) {
pthread_mutex_lock(&g_cloud_mtx);
g_cloud_state.running = 0;
@@ -2293,15 +2372,23 @@
typedef struct {
int is_title;
int auto_job;
save_entry_t save;
char title_id[32];
} cloud_job_args_t;
static int cloud_start_job(cloud_job_args_t *job, char *err, size_t err_sz);
static int ftp_data_zip_write(void *ctx, const void *buf, size_t len) {
int data_sock = *(int *)ctx;
return send_all_fd(data_sock, buf, len);
static int file_zip_write(void *ctx, const void *buf, size_t len) {
int fd = *(int *)ctx;
const char *p = (const char *)buf;
size_t off = 0;
while (off < len) {
ssize_t wr = write(fd, p + off, len - off);
if (wr <= 0) return -1;
off += (size_t)wr;
}
return 0;
}
static void cloud_timestamp(char *out, size_t out_sz) {
@@ -2324,42 +2411,145 @@
snprintf(out, out_sz, "%s/%s/%s", base, title_id, uid);
}
static int cloud_upload_zip_files(const cloud_config_t *cfg, const char *remote_dir,
const char *remote_name, const zip_file_t *files,
int nfiles, char *iobuf, char *err, size_t err_sz) {
ftp_client_t ftp;
if (ftp_connect_login(&ftp, &cfg->ftp, err, err_sz) < 0) return -1;
if (ftp_cwd_mkdirs(&ftp, remote_dir, err, err_sz) < 0) {
ftp_close(&ftp);
return -1;
}
int data_sock = ftp_begin_stor(&ftp, remote_name, err, err_sz);
if (data_sock < 0) {
ftp_close(&ftp);
return -1;
}
zip_sink_t sink;
sink.write = ftp_data_zip_write;
sink.ctx = &data_sock;
sink.bytes = 0;
int zr = zip_write_files(&sink, files, nfiles, iobuf);
if (zr < 0) {
snprintf(err, err_sz, "Failed while streaming ZIP to FTP");
close(data_sock);
ftp_close(&ftp);
return -1;
}
if (ftp_finish_stor(&ftp, data_sock, err, err_sz) < 0) {
ftp_close(&ftp);
return -1;
}
ftp_close(&ftp);
return 0;
static void cloud_backup_file_dir(const char *base_path, const char *title_id,
const char *user_id, char *out, size_t out_sz) {
const char *uid = (user_id && user_id[0]) ? user_id : "all";
char base[MAX_PATH_LEN];
snprintf(base, sizeof(base), "%s", base_path && base_path[0] ? base_path : BACKUP_DEFAULT_LOCAL_DIR);
size_t len = strlen(base);
while (len > 1 && base[len - 1] == '/') base[--len] = 0;
snprintf(out, out_sz, "%s/%s/%s", base, title_id, uid);
}
static int cloud_stage_zip_file(const char *filename, const zip_file_t *files, int nfiles,
char *iobuf, char *out_path, size_t out_path_sz,
char *err, size_t err_sz) {
mkdir("/data", 0777);
mkdir("/data/garlic", 0777);
mkdir(BACKUP_TMP_DIR, 0777);
char final_path[MAX_PATH_LEN], tmp_path[MAX_PATH_LEN];
snprintf(final_path, sizeof(final_path), "%s/%s", BACKUP_TMP_DIR, filename);
snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", final_path);
int fd = open(tmp_path, O_CREAT | O_WRONLY | O_TRUNC, 0600);
if (fd < 0) {
snprintf(err, err_sz, "Cannot create staging ZIP: %s", tmp_path);
return -1;
}
zip_sink_t sink;
sink.write = file_zip_write;
sink.ctx = &fd;
sink.bytes = 0;
int zr = zip_write_files(&sink, files, nfiles, iobuf);
if (zr < 0) {
close(fd);
unlink(tmp_path);
snprintf(err, err_sz, "Failed while writing staging ZIP");
return -1;
}
fsync(fd);
close(fd);
if (rename(tmp_path, final_path) < 0) {
unlink(tmp_path);
snprintf(err, err_sz, "Failed to finalize staging ZIP");
return -1;
}
snprintf(out_path, out_path_sz, "%s", final_path);
return 0;
}
static int cloud_copy_file_atomic(const char *src, const char *dst, char *err, size_t err_sz) {
char tmp[MAX_PATH_LEN];
snprintf(tmp, sizeof(tmp), "%s.tmp", dst);
if (copy_file(src, tmp) < 0) {
unlink(tmp);
snprintf(err, err_sz, "Failed to copy backup file");
return -1;
}
if (rename(tmp, dst) < 0) {
unlink(tmp);
snprintf(err, err_sz, "Failed to finalize backup file");
return -1;
}
return 0;
}
static int cloud_copy_staged_zip_target(const char *stage_path, const char *base_path,
const char *title_id, const char *user_id,
const char *filename, char *out_path, size_t out_path_sz,
char *err, size_t err_sz) {
if (!base_path || !base_path[0]) {
snprintf(err, err_sz, "Backup folder is not configured");
return -1;
}
char dir[MAX_PATH_LEN];
cloud_backup_file_dir(base_path, title_id, user_id, dir, sizeof(dir));
recursive_mkdir(dir);
char final_path[MAX_PATH_LEN];
snprintf(final_path, sizeof(final_path), "%s/%s", dir, filename);
if (cloud_copy_file_atomic(stage_path, final_path, err, err_sz) < 0) return -1;
if (out_path && out_path_sz)
snprintf(out_path, out_path_sz, "%s", final_path);
return 0;
}
static int cloud_queue_staged_zip(const char *stage_path, const char *filename,
char *err, size_t err_sz) {
mkdir("/data", 0777);
mkdir("/data/garlic", 0777);
mkdir(CLOUD_QUEUE_DIR, 0777);
char path[MAX_PATH_LEN];
snprintf(path, sizeof(path), "%s/%s", CLOUD_QUEUE_DIR, filename);
return cloud_copy_file_atomic(stage_path, path, err, err_sz);
}
static int cloud_upload_zip_file(const cloud_config_t *cfg, const char *remote_dir,
const char *remote_name, const char *stage_path,
char *iobuf, char *err, size_t err_sz) {
ftp_client_t ftp;
if (ftp_connect_login(&ftp, &cfg->ftp, err, err_sz) < 0) return -1;
if (ftp_cwd_mkdirs(&ftp, remote_dir, err, err_sz) < 0) {
ftp_close(&ftp);
return -1;
}
int data_sock = ftp_begin_stor(&ftp, remote_name, err, err_sz);
if (data_sock < 0) {
ftp_close(&ftp);
return -1;
}
int fd = open(stage_path, O_RDONLY);
if (fd < 0) {
close(data_sock);
ftp_close(&ftp);
snprintf(err, err_sz, "Cannot read staging ZIP");
return -1;
}
ssize_t nr;
while ((nr = read(fd, iobuf, BUF_SIZE)) > 0) {
if (send_all_fd(data_sock, iobuf, (size_t)nr) < 0) {
close(fd);
close(data_sock);
ftp_close(&ftp);
snprintf(err, err_sz, "Failed while streaming staged ZIP to FTP");
return -1;
}
}
close(fd);
if (nr < 0) {
close(data_sock);
ftp_close(&ftp);
snprintf(err, err_sz, "Failed to read staging ZIP");
return -1;
}
if (ftp_finish_stor(&ftp, data_sock, err, err_sz) < 0) {
ftp_close(&ftp);
return -1;
}
ftp_close(&ftp);
return 0;
}
static int cloud_build_single_files(const save_entry_t *s, zip_file_t *files,
char names[][512], char *bin_path,
int *nfiles) {
@@ -2618,65 +2808,26 @@
return count;
}
static int cloud_queue_title_locked(const char *title_id, char *err, size_t err_sz) {
mkdir("/data", 0777);
mkdir("/data/garlic", 0777);
mkdir(CLOUD_QUEUE_DIR, 0777);
char *iobuf = malloc(BUF_SIZE);
if (!iobuf) { snprintf(err, err_sz, "Out of memory"); return -1; }
mem_buf_t pkg;
char filename[256];
int ret = cloud_package_title(title_id, iobuf, &pkg, filename, sizeof(filename), err, err_sz);
free(iobuf);
if (ret < 0) return -1;
char path[MAX_PATH_LEN];
snprintf(path, sizeof(path), "%s/%s", CLOUD_QUEUE_DIR, filename);
int fd = open(path, O_CREAT | O_WRONLY | O_TRUNC, 0600);
if (fd < 0) {
free(pkg.data);
snprintf(err, err_sz, "Cannot create queue file");
return -1;
}
size_t off = 0;
while (off < pkg.len) {
ssize_t wr = write(fd, pkg.data + off, pkg.len - off);
if (wr <= 0) { close(fd); free(pkg.data); snprintf(err, err_sz, "Queue write failed"); return -1; }
off += wr;
}
close(fd);
free(pkg.data);
return 0;
}
static int auto_trigger_title(const char *title_id, const cloud_config_t *cfg) {
char err[256] = {0};
if (!cloud_title_is_watched(cfg, title_id)) return 0;
if (strcmp(cfg->provider, "ftp") == 0) {
if (cfg->targets.local.enabled || cfg->targets.usb.enabled || cfg->targets.ftp_enabled ||
(cfg->targets.drive_enabled && cfg->auto_backup.queue_for_browser_drive &&
strcmp(cfg->drive.mode, "browser") == 0)) {
cloud_job_args_t *job = calloc(1, sizeof(*job));
if (!job) { auto_set_result(title_id, "Auto backup failed: out of memory"); return -1; }
job->is_title = 1;
job->auto_job = 1;
snprintf(job->title_id, sizeof(job->title_id), "%s", title_id);
if (cloud_start_job(job, err, sizeof(err)) < 0) {
free(job);
auto_set_result(title_id, "Auto FTP backup failed: %s", err);
auto_set_result(title_id, "Auto backup failed: %s", err);
return -1;
}
auto_set_result(title_id, "Auto FTP backup started");
auto_set_result(title_id, "Auto backup started");
return 0;
}
if (strcmp(cfg->provider, "google_drive") == 0 && cfg->auto_backup.queue_for_browser_drive) {
pthread_mutex_lock(&g_mtx);
scan_saves();
int ret = cloud_queue_title_locked(title_id, err, sizeof(err));
pthread_mutex_unlock(&g_mtx);
if (ret < 0) {
auto_set_result(title_id, "Auto Drive queue failed: %s", err);
return -1;
}
auto_set_result(title_id, "Queued Google Drive backup");
return 0;
}
auto_set_result(title_id, "Auto backup skipped for provider %s", cfg->provider);
auto_set_result(title_id, "Auto backup skipped: no enabled backup targets");
return 0;
}
@@ -2793,16 +2944,6 @@
free(job);
return NULL;
}
if (strcmp(cfg.provider, "google_drive") == 0) {
if (strcmp(cfg.drive.mode, "browser") == 0) {
cloud_set_done(0, "Google Drive browser mode uploads from the web UI; use Settings and Cloud Backup again", NULL);
} else {
cloud_set_done(0, "Direct Google Drive needs HTTPS/TLS support not available in this payload build", NULL);
}
free(job);
return NULL;
}
char *iobuf = malloc(BUF_SIZE);
if (!iobuf) {
cloud_set_done(0, "Out of memory", NULL);
@@ -2896,9 +3037,136 @@
mtx_locked = 0;
}
if (upload_files && upload_nfiles > 0 && !err[0])
ret = cloud_upload_zip_files(&cfg, remote_dir, remote_name, upload_files, upload_nfiles,
iobuf, err, sizeof(err));
int final_success_count = 0, final_target_count = 0;
if (upload_files && upload_nfiles > 0 && !err[0]) {
int success_count = 0, fail_count = 0, target_done = 0;
int target_total = 0;
char target_path[MAX_PATH_LEN] = {0};
char stage_path[MAX_PATH_LEN] = {0};
char all_paths[1024] = {0};
char target_err[256] = {0};
char step_msg[128] = {0};
const char *target_title = job->is_title ? job->title_id : job->save.title_id;
const char *target_user = job->is_title ? "all" : job->save.user_id;
int drive_queue_enabled = job->auto_job && cfg.targets.drive_enabled &&
cfg.auto_backup.queue_for_browser_drive &&
job->is_title && strcmp(cfg.drive.mode, "browser") == 0;
if (cfg.targets.local.enabled) target_total++;
if (cfg.targets.usb.enabled) target_total++;
if (cfg.targets.ftp_enabled) target_total++;
if (drive_queue_enabled) target_total++;
final_target_count = target_total;
if (target_total <= 0) {
snprintf(err, sizeof(err), "No backup targets are enabled");
ret = -1;
} else {
cloud_set_step(30, "Packaging backup ZIP", remote_full);
if (cloud_stage_zip_file(remote_name, upload_files, upload_nfiles, iobuf,
stage_path, sizeof(stage_path), err, sizeof(err)) < 0) {
ret = -1;
}
}
if (stage_path[0] && cfg.targets.local.enabled) {
target_err[0] = 0;
target_done++;
int ok = cloud_copy_staged_zip_target(stage_path, cfg.targets.local.path,
target_title, target_user, remote_name,
target_path, sizeof(target_path),
target_err, sizeof(target_err)) == 0;
if (ok) {
success_count++;
snprintf(all_paths + strlen(all_paths), sizeof(all_paths) - strlen(all_paths),
"%slocal:%s", all_paths[0] ? "; " : "", target_path);
} else {
fail_count++;
snprintf(err + strlen(err), sizeof(err) - strlen(err), "%slocal: %s",
err[0] ? "; " : "", target_err);
}
snprintf(step_msg, sizeof(step_msg), "Local backup %s (%d/%d)",
ok ? "done" : "failed", target_done, target_total);
cloud_set_step(35 + (target_done * 60 / target_total), step_msg,
ok ? target_path : remote_full);
notify("Garlic SaveMgr: %s", step_msg);
}
if (stage_path[0] && cfg.targets.usb.enabled) {
target_err[0] = 0;
target_done++;
int ok = cloud_copy_staged_zip_target(stage_path, cfg.targets.usb.path,
target_title, target_user, remote_name,
target_path, sizeof(target_path),
target_err, sizeof(target_err)) == 0;
if (ok) {
success_count++;
snprintf(all_paths + strlen(all_paths), sizeof(all_paths) - strlen(all_paths),
"%susb:%s", all_paths[0] ? "; " : "", target_path);
} else {
fail_count++;
snprintf(err + strlen(err), sizeof(err) - strlen(err), "%susb: %s",
err[0] ? "; " : "", target_err);
}
snprintf(step_msg, sizeof(step_msg), "USB backup %s (%d/%d)",
ok ? "done" : "failed", target_done, target_total);
cloud_set_step(35 + (target_done * 60 / target_total), step_msg,
ok ? target_path : remote_full);
notify("Garlic SaveMgr: %s", step_msg);
}
if (stage_path[0] && cfg.targets.ftp_enabled) {
target_err[0] = 0;
target_done++;
int ok = cloud_upload_zip_file(&cfg, remote_dir, remote_name, stage_path,
iobuf, target_err, sizeof(target_err)) == 0;
if (ok) {
success_count++;
snprintf(all_paths + strlen(all_paths), sizeof(all_paths) - strlen(all_paths),
"%sftp:%s", all_paths[0] ? "; " : "", remote_full);
} else {
fail_count++;
snprintf(err + strlen(err), sizeof(err) - strlen(err), "%sftp: %s",
err[0] ? "; " : "", target_err);
}
snprintf(step_msg, sizeof(step_msg), "FTP backup %s (%d/%d)",
ok ? "done" : "failed", target_done, target_total);
cloud_set_step(35 + (target_done * 60 / target_total), step_msg, remote_full);
notify("Garlic SaveMgr: %s", step_msg);
}
if (stage_path[0] && drive_queue_enabled) {
target_err[0] = 0;
target_done++;
int ok = cloud_queue_staged_zip(stage_path, remote_name, target_err, sizeof(target_err)) == 0;
if (ok) {
success_count++;
snprintf(all_paths + strlen(all_paths), sizeof(all_paths) - strlen(all_paths),
"%sdrive_queue:%s", all_paths[0] ? "; " : "", remote_name);
} else {
fail_count++;
snprintf(err + strlen(err), sizeof(err) - strlen(err), "%sdrive_queue: %s",
err[0] ? "; " : "", target_err);
}
snprintf(step_msg, sizeof(step_msg), "Drive queue %s (%d/%d)",
ok ? "done" : "failed", target_done, target_total);
cloud_set_step(35 + (target_done * 60 / target_total), step_msg, remote_name);
notify("Garlic SaveMgr: %s", step_msg);
}
final_success_count = success_count;
if (success_count > 0) {
ret = 0;
snprintf(remote_full, sizeof(remote_full), "%d/%d targets complete: %s",
success_count, target_total, all_paths);
if (fail_count > 0)
logprintf("[GarlicMgr] Cloud backup partial failures: %s\n", err);
} else if (target_total > 0) {
ret = -1;
if (!err[0]) snprintf(err, sizeof(err), "All backup targets failed");
}
if (stage_path[0]) unlink(stage_path);
}
if (path_copies) {
for (int i = 0; i < path_copy_count; i++) free(path_copies[i]);
@@ -2910,11 +3178,17 @@
if (ret == 0) {
logprintf("[GarlicMgr] Cloud upload complete: %s\n", remote_full);
notify("Garlic SaveMgr: cloud backup complete");
if (final_target_count > 0)
notify("Garlic SaveMgr: backup %d/%d done", final_success_count, final_target_count);
else
notify("Garlic SaveMgr: cloud backup complete");
cloud_set_done(1, NULL, remote_full);
} else {
logprintf("[GarlicMgr] Cloud upload failed: %s\n", err);
notify("Garlic SaveMgr: cloud backup failed");
if (final_target_count > 0)
notify("Garlic SaveMgr: backup %d/%d done", final_success_count, final_target_count);
else
notify("Garlic SaveMgr: cloud backup failed");
cloud_set_done(0, err[0] ? err : "Cloud upload failed", remote_full);
}
free(job);
@@ -3278,8 +3552,8 @@
http_json(sock, "{\"ok\":false,\"stage\":\"config\",\"error\":\"Cloud backup is disabled\"}");
return;
}
if (strcmp(cfg.provider, "ftp") != 0) {
http_json(sock, "{\"ok\":false,\"stage\":\"config\",\"error\":\"Provider is not FTP\"}");
if (!cfg.targets.ftp_enabled) {
http_json(sock, "{\"ok\":false,\"stage\":\"config\",\"error\":\"FTP backup target is not enabled\"}");
return;
}
+46 -3
View File
@@ -1058,6 +1058,11 @@ async function renderSettings() {
const ftp = cfg.ftp || {};
const gd = cfg.google_drive || {};
const auto = cfg.auto_backup || {};
const targets = cfg.backup_targets || {};
const localTarget = targets.local || {};
const usbTarget = targets.usb || {};
const ftpTarget = targets.ftp || {};
const driveTarget = targets.google_drive || {};
let driveAuth = gd.auth_mode || localStorage.getItem('gdrive_auth_mode') || 'oauth_refresh';
if (driveAuth === 'oauth_token') driveAuth = 'oauth_refresh';
const accessToken = gd.access_token || localStorage.getItem('gdrive_access_token') || '';
@@ -1097,6 +1102,20 @@ async function renderSettings() {
+ '<input id="setAutoQueueDrive" type="checkbox" ' + (auto.queue_for_browser_drive !== false ? 'checked' : '') + ' style="accent-color:var(--ac);width:16px;height:16px"> Queue Google Drive backups for browser upload</label>'
+ '<div id="autoStatus" style="font-size:11px;color:var(--dm);margin-top:8px">Auto status loading...</div>'
+ '</div>'
+ '<div style="margin-top:14px">'
+ '<div style="font-size:13px;font-weight:700;color:var(--tx);margin-bottom:8px">Backup Destinations</div>'
+ '<label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--tx);font-weight:700;margin-top:10px">'
+ '<input id="setTargetLocal" type="checkbox" ' + (localTarget.enabled ? 'checked' : '') + ' style="accent-color:var(--ac);width:16px;height:16px"> Local PS5 folder</label>'
+ settingsInput('setTargetLocalPath', 'Local Folder', localTarget.path || '/data/garlic/backups')
+ '<label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--tx);font-weight:700;margin-top:10px">'
+ '<input id="setTargetUsb" type="checkbox" ' + (usbTarget.enabled ? 'checked' : '') + ' style="accent-color:var(--ac);width:16px;height:16px"> USB folder</label>'
+ settingsInput('setTargetUsbPath', 'USB Folder', usbTarget.path || '/mnt/usb0/garlic-backups')
+ '<label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--tx);font-weight:700;margin-top:10px">'
+ '<input id="setTargetFtp" type="checkbox" ' + (ftpTarget.enabled || (!targets.ftp && cfg.provider === 'ftp') ? 'checked' : '') + ' style="accent-color:var(--ac);width:16px;height:16px"> FTP</label>'
+ '<label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--tx);font-weight:700;margin-top:10px">'
+ '<input id="setTargetDrive" type="checkbox" ' + (driveTarget.enabled || (!targets.google_drive && cfg.provider === 'google_drive') ? 'checked' : '') + ' style="accent-color:var(--ac);width:16px;height:16px"> Google Drive browser upload</label>'
+ '<div style="font-size:11px;color:var(--dm);margin-top:6px">Manual and auto title backups write one encrypted/restorable ZIP to every enabled destination. Drive uploads are handled by the browser queue.</div>'
+ '</div>'
+ '<div id="driveSettings" style="margin-top:14px">'
+ '<div style="font-size:13px;font-weight:700;color:var(--tx);margin-bottom:8px">Google Drive</div>'
+ '<label style="display:block;margin:8px 0 4px;font-size:10px;color:var(--dm);font-weight:700;text-transform:uppercase;letter-spacing:1px">Mode</label>'
@@ -1193,6 +1212,18 @@ async function saveCloudSettings() {
remote_path: $('setFtpPath').value.trim() || '/',
use_tls: $('setFtpTls').checked
},
backup_targets: {
local: {
enabled: $('setTargetLocal').checked,
path: $('setTargetLocalPath').value.trim() || '/data/garlic/backups'
},
usb: {
enabled: $('setTargetUsb').checked,
path: $('setTargetUsbPath').value.trim() || '/mnt/usb0/garlic-backups'
},
ftp: { enabled: $('setTargetFtp').checked },
google_drive: { enabled: $('setTargetDrive').checked }
},
google_drive: {
mode: $('setDriveMode').value,
auth_mode: driveAuth,
@@ -3191,7 +3222,9 @@ async function loadCloudConfigForAction() {
async function googleDriveBackup(packageUrl, fallbackName, label) {
const cfg = await loadCloudConfigForAction();
const gd = cfg.google_drive || {};
if (cfg.provider !== 'google_drive' || (gd.mode || 'browser') !== 'browser') return false;
const targets = cfg.backup_targets || {};
const driveTarget = (targets.google_drive && targets.google_drive.enabled) || cfg.provider === 'google_drive';
if (!driveTarget || (gd.mode || 'browser') !== 'browser') return false;
log('Packaging ' + label + ' for Google Drive...');
const pr = await fetch(packageUrl);
const ct = pr.headers.get('Content-Type') || '';
@@ -3208,6 +3241,14 @@ async function googleDriveBackup(packageUrl, fallbackName, label) {
return true;
}
function hasPs5BackupTargets(cfg) {
const targets = cfg.backup_targets || {};
return !!((targets.local && targets.local.enabled)
|| (targets.usb && targets.usb.enabled)
|| (targets.ftp && targets.ftp.enabled)
|| (!targets.ftp && cfg.provider === 'ftp'));
}
let queueDraining = false;
async function refreshAutoStatus() {
@@ -3289,10 +3330,11 @@ async function cloudBackupSave(idx) {
mt = false; cf = [];
bsy = false; renderTB();
}
const cfg = await loadCloudConfigForAction();
const handled = await googleDriveBackup('/api/cloud/package?idx=' + idx,
s.title_id + '_' + s.save_name + '_enc.zip',
s.title_id + '/' + s.save_name);
if (handled) return;
if (handled && !hasPs5BackupTargets(cfg)) return;
cloudBusy = true;
log('Starting cloud backup for ' + s.title_id + '/' + s.save_name + '...');
const r = await fetch('/api/cloud/upload?idx=' + idx + '&mode=enc');
@@ -3318,10 +3360,11 @@ async function cloudBackupTitle(titleId) {
mt = false; cf = [];
bsy = false; renderTB();
}
const cfg = await loadCloudConfigForAction();
const handled = await googleDriveBackup('/api/cloud/package_title?id=' + encodeURIComponent(titleId),
titleId + '_all_enc.zip',
'all saves in ' + titleId);
if (handled) return;
if (handled && !hasPs5BackupTargets(cfg)) return;
cloudBusy = true;
log('Starting cloud backup for all saves in ' + titleId + '...');
const r = await fetch('/api/cloud/upload_title?id=' + encodeURIComponent(titleId) + '&mode=enc');