Compare commits

...
6 Commits
Author SHA1 Message Date
owendswang 34de7d2483 Fixed PKG install function for game base PKG 2026-08-23 21:30:20 +08:00
owendswang bd4d1c45a6 Able to change permissions now. Click the persmissions column and edit permissions in the pop-up diag. 2026-08-20 16:53:08 +08:00
owendswang 28230105c4 Optimize upload/download via remote web browser 2026-08-17 11:32:41 +08:00
owendswang c7fc108e48 Change deivces at the top-right corner clickable. 2026-08-17 01:48:18 +08:00
owendswang 48d669b848 Circle button to go back to parent folder
Block browser closing while processing tasks and displaying overlay

Auto focus to parent folder after going back
2026-08-11 22:01:25 +08:00
owendswang dafbd8dcbf Optimize address bar for path display overflow 2026-08-09 14:50:38 +08:00
15 changed files with 1080 additions and 113 deletions

No files matched your search

+1 -1
View File
@@ -13,7 +13,7 @@ ifeq ($(MAKECMDGOALS),)
endif
endif
VERSION_TAG := v1.3
VERSION_TAG := v1.6
TITLE_ID := FMGR88888
PYTHON ?= python3
STRIP ?= $(PS5_PAYLOAD_SDK)/bin/prospero-strip
+2
View File
@@ -22,6 +22,7 @@ PS5 web file manager payload. It runs an HTTP UI starting at port `8888`, instal
- List files and folders.
- Sort the list by name, type, size, modified time, or permissions. The selected sort mode is saved in browser local storage.
- Change read/write/execute permissions from the permissions column, using checkboxes or a validated four-digit octal mode.
- Copy, move, delete, rename, create files, and create folders.
- Edit UTF-8 text files up to 1 MiB using the built-in plain text editor.
- Multi-select operations.
@@ -123,6 +124,7 @@ This project was built with reference to these projects:
- **[ps5-payload-dev/websrv](https://github.com/ps5-payload-dev/websrv):** HTTP server structure, static asset embedding ideas, PS5 browser/websrv behavior and PKG install function. License: GPLv3+.
- **[ps5-payload-dev/ftpsrv](https://github.com/ps5-payload-dev/ftpsrv):** PS5 payload conventions, home screen launcher/install flow reference, process handling style and startup installation reference. License: GPLv3+.
- **[seregonwar/zftpd](https://github.com/seregonwar/zftpd):** PS5 TCP socket buffer tuning and high-throughput transfer behavior reference. License: MIT.
- **[itsPLK/ps5-payload-manager](https://github.com/itsPLK/ps5-payload-manager):** Payload building behavior. License: GPLv3.
- **[libmicrohttpd](https://ftp.gnu.org/gnu/libmicrohttpd/):** Used as the embedded HTTP server library. It is licensed by GNU under the LGPL; this payload links it as the SDK-provided static library.
- **[ps5-payload-dev/sdk](https://github.com/ps5-payload-dev/sdk):** Payload building foundation. License: GPLv3+.
+44
View File
@@ -123,6 +123,50 @@
</section>
</div>
<div id="permissionOverlay" class="permission-overlay" hidden>
<section class="permission-panel" role="dialog" aria-modal="true" aria-labelledby="permissionTitle">
<h2 id="permissionTitle" class="permission-title" data-i18n="permissionsTitle"></h2>
<div id="permissionPath" class="permission-path"></div>
<div class="permission-grid">
<div class="permission-row permission-head" aria-hidden="true">
<span class="permission-scope"></span>
<span>R</span><span>W</span><span>X</span>
</div>
<div class="permission-row">
<span class="permission-scope" data-i18n="permissionOwner"></span>
<label><input id="permissionOwnerRead" type="checkbox"><span>R</span></label>
<label><input id="permissionOwnerWrite" type="checkbox"><span>W</span></label>
<label><input id="permissionOwnerExecute" type="checkbox"><span>X</span></label>
</div>
<div class="permission-row">
<span class="permission-scope" data-i18n="permissionGroup"></span>
<label><input id="permissionGroupRead" type="checkbox"><span>R</span></label>
<label><input id="permissionGroupWrite" type="checkbox"><span>W</span></label>
<label><input id="permissionGroupExecute" type="checkbox"><span>X</span></label>
</div>
<div class="permission-row">
<span class="permission-scope" data-i18n="permissionOther"></span>
<label><input id="permissionOtherRead" type="checkbox"><span>R</span></label>
<label><input id="permissionOtherWrite" type="checkbox"><span>W</span></label>
<label><input id="permissionOtherExecute" type="checkbox"><span>X</span></label>
</div>
</div>
<label class="permission-octal">
<span data-i18n="permissionOctal"></span>
<input id="permissionMode" type="text" inputmode="numeric" pattern="0[0-7]{3}"
minlength="4" maxlength="4" autocomplete="off" spellcheck="false">
</label>
<label id="permissionRecursiveOption" class="permission-recursive" hidden>
<input id="permissionRecursive" type="checkbox" checked>
<span data-i18n="permissionRecursive"></span>
</label>
<div class="permission-actions">
<button id="permissionCancelBtn" class="secondary" data-i18n="close"></button>
<button id="permissionApplyBtn" class="primary" data-i18n="apply"></button>
</div>
</section>
</div>
<div id="taskOverlay" class="task-overlay" hidden>
<div class="task-panel">
<div id="tasks" class="tasks"></div>
+19
View File
@@ -9,11 +9,13 @@ window.WFM_LANG = {
copying: "Copying",
moving: "Moving",
deleting: "Deleting",
changingPermissions: "Changing permissions",
rename: "Rename",
paste: "Paste",
cancel: "Cancel",
close: "Close",
save: "Save",
apply: "Apply",
exit: "Exit",
exiting: "Exiting...",
refresh: "Refresh",
@@ -33,6 +35,19 @@ window.WFM_LANG = {
size: "Size",
mtime: "Modified",
mode: "Mode",
permissionsTitle: "Change permissions",
permissionOwner: "Owner",
permissionGroup: "Group",
permissionOther: "Other",
permissionOctal: "Octal mode",
permissionRecursive: "Apply to all contents inside this folder",
permissionObjectCount: "and {count} objects",
permissionChangeTitle: "Change permissions for {name}",
permissionInvalid: "Enter exactly four octal digits from 0 to 7, starting with 0. The original value has been restored.",
unsavedPermissionConfirm: "The permissions have unsaved changes. Close without applying them?\n\nCancel keeps the permissions dialog open.",
permissionChanging: "Changing permissions...",
permissionChanged: "Permissions changed: {name} → {mode}",
permissionChangeFailed: "Failed to change permissions: {error}",
empty: "Folder is empty",
ready: "Ready",
freeSpace: "Free space",
@@ -46,7 +61,9 @@ window.WFM_LANG = {
checking: "Checking",
pleaseWait: "Please wait",
speedLabel: "Speed",
itemsPerSecond: "{count} objects/s",
progressLabel: "Progress",
permissionProgress: "{done} / {total} objects",
etaLabel: "ETA",
elapsedLabel: "Elapsed",
durationHours: "{hours}h {minutes}m",
@@ -124,6 +141,8 @@ window.WFM_LANG = {
err_source_destination_same: "Source and destination are the same",
err_destination_inside_source: "Cannot copy or move a folder inside itself",
err_invalid_path: "Invalid path",
err_invalid_mode: "Invalid permission mode",
err_chmod_failed: "Cannot change permissions: {path}",
err_pkg_type_invalid: "Only .pkg files can be installed",
err_pkg_install_failed: "Package installation failed ({arg})",
err_pkg_install_unsupported: "Package installation is only available on PS5",
+19
View File
@@ -9,11 +9,13 @@ window.WFM_LANG = {
copying: "复制",
moving: "移动",
deleting: "删除",
changingPermissions: "修改权限",
rename: "重命名",
paste: "粘贴",
cancel: "取消",
close: "关闭",
save: "保存",
apply: "应用",
exit: "退出",
exiting: "正在退出...",
refresh: "刷新",
@@ -33,6 +35,19 @@ window.WFM_LANG = {
size: "大小",
mtime: "修改时间",
mode: "权限",
permissionsTitle: "修改权限",
permissionOwner: "拥有者",
permissionGroup: "组",
permissionOther: "其他",
permissionOctal: "八进制表示",
permissionRecursive: "应用于文件夹内的所有内容",
permissionObjectCount: "等 {count} 个对象",
permissionChangeTitle: "修改 {name} 的权限",
permissionInvalid: "请输入以 0 开头、由 0 到 7 组成的四位八进制权限。已恢复为原权限值。",
unsavedPermissionConfirm: "权限有未保存的修改,是否不保存并关闭?\n\n取消将留在权限弹窗中。",
permissionChanging: "正在修改权限...",
permissionChanged: "权限已修改:{name} → {mode}",
permissionChangeFailed: "修改权限失败:{error}",
empty: "目录为空",
ready: "就绪",
freeSpace: "可用空间",
@@ -46,7 +61,9 @@ window.WFM_LANG = {
checking: "检查中",
pleaseWait: "请稍候",
speedLabel: "速度",
itemsPerSecond: "{count} 个对象/秒",
progressLabel: "进度",
permissionProgress: "{done} / {total} 个对象",
etaLabel: "剩余",
elapsedLabel: "耗时",
durationHours: "{hours}小时 {minutes}分",
@@ -124,6 +141,8 @@ window.WFM_LANG = {
err_source_destination_same: "源和目标相同",
err_destination_inside_source: "不能复制或移动目录到它自己的内部",
err_invalid_path: "路径无效",
err_invalid_mode: "权限格式无效",
err_chmod_failed: "无法修改权限:{path}",
err_pkg_type_invalid: "只能安装 .pkg 文件",
err_pkg_install_failed: "PKG 安装失败({arg})",
err_pkg_install_unsupported: "PKG 安装仅支持 PS5 环境",
+224 -5
View File
@@ -65,13 +65,13 @@ body {
}
.top-left {
flex: 1 1 420px;
flex: 1 1 0;
min-width: 0;
margin-top: 15px;
}
.top-right {
flex: 1 1 360px;
flex: 0 1 auto;
min-width: 0;
margin-left: auto;
justify-content: flex-end;
@@ -88,6 +88,7 @@ body {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
flex: 1 1 auto;
min-width: 0;
}
@@ -109,19 +110,55 @@ body {
text-align: right;
}
.space-item {
.space-info .space-item {
flex: 0 0 auto;
display: inline-flex;
height: auto;
min-height: 0;
min-width: 0;
margin-left: 14px;
padding: 0 2px;
border: 0;
border-radius: 3px;
background: transparent;
color: inherit;
font: inherit;
line-height: inherit;
text-align: right;
cursor: pointer;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.space-info .space-item:hover:not(:disabled),
.space-info .space-item:focus:not(:disabled) {
color: #fff;
background: rgba(255, 255, 255, 0.1);
outline: none;
}
.space-info .space-item:focus-visible {
box-shadow: 0 0 0 2px #87bfff;
}
.space-info .space-item:active {
background: rgba(255, 255, 255, 0.18);
}
.space-info .space-item:disabled {
opacity: 1;
color: inherit;
cursor: default;
pointer-events: none;
}
.space-prefix {
color: #8f9aa5;
flex: 0 0 auto;
}
.space-item.current {
.space-info .space-item.current {
color: #f2f5f7;
font-weight: 700;
}
@@ -551,6 +588,22 @@ th.size-col {
text-align: center;
}
td.mode-col {
padding: 0;
}
.mode-action {
width: 100%;
min-width: 0;
height: 78px;
min-height: 78px;
padding: 0 8px;
border: 0;
border-radius: 0;
background: transparent;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.select-cell {
padding: 0;
text-align: center;
@@ -765,6 +818,162 @@ input[type="checkbox"] {
margin-top: 14px;
}
.permission-overlay {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 9500;
display: flex;
align-items: center;
justify-content: center;
padding: 48px;
background: rgba(4, 6, 8, .9);
}
.permission-panel {
width: 620px;
max-width: calc(100vw - 96px);
padding: 28px;
border: 1px solid #46515f;
border-radius: 10px;
background: #15191e;
box-shadow: 0 18px 60px rgba(0, 0, 0, .45);
}
.permission-title {
margin: 0;
color: #edf0f2;
font-size: 26px;
font-weight: 600;
}
.permission-path {
display: flex;
margin: 10px 0 22px;
color: #9ba7b2;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 17px;
white-space: nowrap;
}
.permission-path-names {
min-width: 0;
flex: 1 1 0;
overflow: hidden;
text-overflow: ellipsis;
}
.permission-path-count {
flex: 0 0 auto;
margin-left: 8px;
}
.permission-grid {
padding: 12px 0;
border-top: 1px solid #2d343d;
border-bottom: 1px solid #2d343d;
}
.permission-row {
display: flex;
min-height: 50px;
align-items: center;
}
.permission-row > * {
width: 96px;
flex: 0 0 96px;
text-align: center;
}
.permission-row .permission-scope {
width: auto;
min-width: 0;
flex: 1 1 auto;
color: #c9d3dc;
text-align: left;
}
.permission-head {
min-height: 28px;
color: #7f8a94;
font-size: 16px;
}
.permission-row label {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.permission-row label input {
width: 30px;
height: 30px;
margin: 0 8px 0 0;
}
.permission-octal {
display: flex;
align-items: center;
margin: 22px 0;
color: #c9d3dc;
}
.permission-octal span {
flex: 1 1 auto;
}
.permission-octal input {
width: 150px;
height: 50px;
padding: 0 14px;
border: 1px solid #46515f;
border-radius: 6px;
outline: none;
background: #0f1215;
color: #edf0f2;
font: 22px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
text-align: center;
}
.permission-octal input:focus {
border-color: #6fb1ff;
outline: 3px solid rgba(111, 177, 255, .25);
}
.permission-octal input:invalid {
border-color: #b86262;
}
.permission-recursive {
min-height: 46px;
display: flex;
align-items: center;
margin: -8px 0 18px;
color: #c9d3dc;
cursor: pointer;
}
.permission-recursive input {
flex: 0 0 auto;
margin: 0 12px 0 0;
}
.permission-actions {
display: flex;
}
.permission-actions button {
flex: 1 1 0;
}
.permission-actions button + button {
margin-left: 14px;
}
.task-overlay {
position: fixed;
top: 0;
@@ -1070,11 +1279,21 @@ input[type="checkbox"] {
.name-head,
.parent-nav-button,
.select-hit,
.row-action {
.row-action,
.mode-action {
height: 56px;
min-height: 56px;
}
.permission-overlay {
padding: 18px;
}
.permission-panel {
max-width: calc(100vw - 36px);
padding: 18px;
}
.parent-nav-button {
width: 56px;
min-width: 56px;
+387 -34
View File
@@ -25,11 +25,15 @@ let textEditorPath = null;
let textEditorVersion = null;
let textEditorOriginal = "";
let textEditorBusy = false;
let permissionItems = [];
let permissionOriginal = "";
let permissionBusy = false;
let downloadFrame = null;
let uploadXhr = null;
let uploadTerminalAbort = false;
let L = {};
const APP_VERSION = "v1.3";
const APP_VERSION = "v1.6";
const LAST_PATH_KEY = "ps5-web-file-mgr:last-path";
const SORT_KEY = "ps5-web-file-mgr:list-sort";
const LOADING_DISPLAY_DELAY = 250;
@@ -80,6 +84,24 @@ const imagePreviewOverlayEl = document.getElementById("imagePreviewOverlay");
const imagePreviewNameEl = document.getElementById("imagePreviewName");
const imagePreviewEl = document.getElementById("imagePreview");
const imagePreviewCloseBtn = document.getElementById("imagePreviewCloseBtn");
const permissionOverlayEl = document.getElementById("permissionOverlay");
const permissionPathEl = document.getElementById("permissionPath");
const permissionModeEl = document.getElementById("permissionMode");
const permissionRecursiveOptionEl = document.getElementById("permissionRecursiveOption");
const permissionRecursiveEl = document.getElementById("permissionRecursive");
const permissionCancelBtn = document.getElementById("permissionCancelBtn");
const permissionApplyBtn = document.getElementById("permissionApplyBtn");
const permissionChecks = [
document.getElementById("permissionOwnerRead"),
document.getElementById("permissionOwnerWrite"),
document.getElementById("permissionOwnerExecute"),
document.getElementById("permissionGroupRead"),
document.getElementById("permissionGroupWrite"),
document.getElementById("permissionGroupExecute"),
document.getElementById("permissionOtherRead"),
document.getElementById("permissionOtherWrite"),
document.getElementById("permissionOtherExecute")
];
function chooseLanguage() {
const langs = navigator.languages && navigator.languages.length ? navigator.languages : [navigator.language || ""];
@@ -316,6 +338,23 @@ function displayPath(path) {
return decodeFsText(path);
}
function renderAddressPath() {
const path = displayPath(cwd);
pathEl.title = path;
pathEl.textContent = path;
if (pathEl.scrollWidth <= pathEl.clientWidth) return;
let low = 0;
let high = path.length;
while (low < high) {
const count = Math.ceil((low + high) / 2);
pathEl.textContent = "..." + path.slice(-count);
if (pathEl.scrollWidth <= pathEl.clientWidth) low = count;
else high = count - 1;
}
pathEl.textContent = "..." + path.slice(-low);
}
async function fetchText(path, params) {
const response = await request(path, params, { method: "GET" });
return {
@@ -381,11 +420,11 @@ function taskElapsed(task) {
}
function opLabel(op) {
return { copy: t("copy"), move: t("move"), delete: t("delete"), download: t("download"), upload: t("upload") }[op] || op;
return { copy: t("copy"), move: t("move"), delete: t("delete"), chmod: t("permissionsTitle"), download: t("download"), upload: t("upload") }[op] || op;
}
function taskOpLabel(op) {
return { copy: t("copying"), move: t("moving"), delete: t("deleting"), download: t("downloading"), upload: t("uploading") }[op] || op;
return { copy: t("copying"), move: t("moving"), delete: t("deleting"), chmod: t("changingPermissions"), download: t("downloading"), upload: t("uploading") }[op] || op;
}
function isPlayStationBrowser() {
@@ -420,6 +459,10 @@ function clearTrackedTask() {
downloadFrame.parentNode.removeChild(downloadFrame);
downloadFrame = null;
}
if (trackedTask && trackedTask.op === "upload" && uploadXhr) {
uploadTerminalAbort = true;
uploadXhr.abort();
}
trackedTask = null;
}
@@ -465,6 +508,12 @@ function setBusy(value) {
updateButtons();
}
function setModalBackgroundLocked(value) {
contentEl.classList.toggle("loading", value);
if (value) clearHoverPath();
updateButtons();
}
function showTaskOverlay(immediate, delay) {
setBusy(true);
if (!overlayEl.hidden || taskOverlayTimer) return;
@@ -498,6 +547,55 @@ function readSavedPath() {
}
}
function historyPath() {
const hash = window.location.hash || "";
if (hash.length <= 1) return "";
try {
const path = decodeURIComponent(hash.slice(1));
return path.charAt(0) === "/" ? path : "";
} catch (err) {
return "";
}
}
function writeHistoryPath(path, replace) {
if (!window.history || !history.pushState) return;
const hash = "#" + encodeURIComponent(path || "/");
if (window.location.hash === hash) return;
const state = { path: path || "/" };
if (replace && history.replaceState) history.replaceState(state, "", hash);
else history.pushState(state, "", hash);
}
function pathHistoryChain(path) {
const clean = String(path || "/").replace(/\/+$/, "") || "/";
const parts = clean.split("/");
const chain = ["/"];
let current = "";
for (let i = 1; i < parts.length; i++) {
if (!parts[i]) continue;
current += "/" + parts[i];
chain.push(current);
}
return chain;
}
function seedHistoryPath(path) {
if (!window.history || !history.pushState || !history.replaceState) return;
const chain = pathHistoryChain(path);
for (let i = 0; i < chain.length; i++) {
writeHistoryPath(chain[i], i === 0);
}
}
function historyBlocked() {
return Boolean(busy || loadingPath || pendingAbortController ||
pendingOverlayText || taskOverlayTimer || !overlayEl.hidden ||
!contentLoadingEl.hidden || contentEl.classList.contains("loading") ||
!textEditorOverlayEl.hidden || !imagePreviewOverlayEl.hidden ||
!permissionOverlayEl.hidden);
}
function savePath(path) {
try {
localStorage.setItem(LAST_PATH_KEY, path || "/");
@@ -539,19 +637,33 @@ async function refreshSpaces() {
spaceInfoEl.appendChild(prefix);
}
for (const item of data.spaces || []) {
const span = document.createElement("span");
span.className = "space-item" + (item.current ? " current" : "");
const button = document.createElement("button");
button.type = "button";
button.className = "space-item" + (item.current ? " current" : "");
const label = item.label_key ? t(item.label_key) : item.label;
span.textContent = label + ": " +
button.textContent = label + ": " +
formatBytes(item.free, false) + "/" + formatBytes(item.total, false);
span.title = item.path + " " + span.textContent;
spaceInfoEl.appendChild(span);
button.title = item.path + " " + button.textContent;
if (item.current) button.setAttribute("aria-current", "location");
button.disabled = spaceNavigationLocked();
bindPress(button, () => {
if (!item.path || spaceNavigationLocked()) return;
load(item.path, undefined, false, true, "push");
});
spaceInfoEl.appendChild(button);
}
renderAddressPath();
} catch (err) {
spaceInfoEl.textContent = "";
renderAddressPath();
}
}
function spaceNavigationLocked() {
return Boolean(busy || loadingPath || !contentLoadingEl.hidden ||
contentEl.classList.contains("loading"));
}
function updateSpecialStoragePaths(spaces) {
const next = {};
let hasMnt = false;
@@ -655,6 +767,7 @@ async function actionInstallSelectedPkgs() {
function openImagePreview(item) {
if (busy) return;
setModalBackgroundLocked(true);
imagePreviewNameEl.textContent = displayName(item);
imagePreviewNameEl.title = displayName(item);
imagePreviewEl.src = "/fs?path=" + encodeURIComponent(item.path);
@@ -667,6 +780,122 @@ function closeImagePreview() {
imagePreviewNameEl.textContent = "";
imagePreviewNameEl.title = "";
imagePreviewEl.removeAttribute("src");
setModalBackgroundLocked(false);
}
function permissionModeText(mode) {
const octal = (Number(mode || 0) & 0x1ff).toString(8);
return "0" + ("000" + octal).slice(-3);
}
function validPermissionMode(value) {
return /^0[0-7]{3}$/.test(String(value || ""));
}
function syncPermissionChecks(value) {
if (!validPermissionMode(value)) return;
const mode = parseInt(value, 8);
const bits = [0x100, 0x080, 0x040, 0x020, 0x010, 0x008, 0x004, 0x002, 0x001];
for (let i = 0; i < permissionChecks.length; i++) {
permissionChecks[i].checked = Boolean(mode & bits[i]);
}
}
function syncPermissionMode() {
const bits = [0x100, 0x080, 0x040, 0x020, 0x010, 0x008, 0x004, 0x002, 0x001];
let mode = 0;
for (let i = 0; i < permissionChecks.length; i++) {
if (permissionChecks[i].checked) mode |= bits[i];
}
permissionModeEl.value = permissionModeText(mode);
}
function validatePermissionMode() {
if (validPermissionMode(permissionModeEl.value)) {
syncPermissionChecks(permissionModeEl.value);
return true;
}
alert(t("permissionInvalid"));
permissionModeEl.value = permissionModeText(permissionItems.length ? permissionItems[0].mode : 0);
syncPermissionChecks(permissionModeEl.value);
permissionModeEl.focus();
permissionModeEl.select();
return false;
}
function setPermissionBusy(value) {
permissionBusy = value;
permissionModeEl.disabled = value;
permissionRecursiveEl.disabled = value;
permissionCancelBtn.disabled = value;
permissionApplyBtn.disabled = value;
for (const checkbox of permissionChecks) checkbox.disabled = value;
}
function openPermissionDialog(item) {
if (busy || loadingPath || permissionBusy) return;
const items = selected.has(item.path) ? selectedEntries() : [item];
setModalBackgroundLocked(true);
permissionItems = items;
renderPermissionItems(items);
permissionModeEl.value = permissionModeText(item.mode);
permissionOriginal = permissionModeEl.value;
permissionRecursiveOptionEl.hidden = !items.some(entry => entry.type === "d");
permissionRecursiveEl.checked = true;
syncPermissionChecks(permissionModeEl.value);
setPermissionBusy(false);
permissionOverlayEl.hidden = false;
permissionModeEl.focus();
permissionModeEl.select();
}
function closePermissionDialog() {
if (permissionBusy) return;
permissionOverlayEl.hidden = true;
permissionItems = [];
permissionOriginal = "";
permissionPathEl.textContent = "";
permissionPathEl.title = "";
permissionRecursiveOptionEl.hidden = true;
setModalBackgroundLocked(false);
}
function requestClosePermissionDialog() {
if (permissionBusy) return;
if (validPermissionMode(permissionModeEl.value) &&
(permissionModeEl.value !== permissionOriginal ||
(!permissionRecursiveOptionEl.hidden && !permissionRecursiveEl.checked)) &&
!confirm(t("unsavedPermissionConfirm"))) return;
closePermissionDialog();
}
async function applyPermissionMode() {
if (permissionBusy || !permissionItems.length || !validatePermissionMode()) return;
const items = permissionItems.slice();
const mode = permissionModeEl.value;
setPermissionBusy(true);
setStatus(t("permissionChanging"));
taskRefreshPath = cwd;
try {
const data = await apiForm("/api/chmod", {
paths: items.map(item => item.path).join("\n"),
mode,
recursive: items.some(item => item.type === "d") && permissionRecursiveEl.checked ? "1" : "0"
});
setPermissionBusy(false);
closePermissionDialog();
setBusy(true);
trackTask(data.task_id, "chmod", false);
clearSelection(false);
setStatus(t("taskCreated", { label: t("permissionsTitle") }));
await pollTasks();
} catch (err) {
setPermissionBusy(false);
const message = t("permissionChangeFailed", { error: err.message });
setStatus(message);
alert(message);
permissionModeEl.focus();
}
}
function setTextEditorBusy(value, status) {
@@ -680,6 +909,7 @@ function setTextEditorBusy(value, status) {
function closeTextEditor() {
if (textEditorBusy) return;
textEditorOverlayEl.hidden = true;
setModalBackgroundLocked(false);
textEditorPath = null;
textEditorVersion = null;
textEditorOriginal = "";
@@ -696,6 +926,7 @@ function requestCloseTextEditor() {
}
function showTextEditor(item, text, version) {
setModalBackgroundLocked(true);
textEditorPath = item.path;
textEditorVersion = version;
textEditorPathEl.textContent = displayPath(item.path);
@@ -709,6 +940,7 @@ function showTextEditor(item, text, version) {
async function openTextEditor(item) {
if (busy || textEditorBusy) return;
setModalBackgroundLocked(true);
textEditorPath = item.path;
textEditorVersion = null;
textEditorOriginal = "";
@@ -723,6 +955,7 @@ async function openTextEditor(item) {
} catch (err) {
setTextEditorBusy(false, "");
textEditorOverlayEl.hidden = true;
setModalBackgroundLocked(false);
textEditorPath = null;
const message = t("openTextFailed", { error: err.message });
setStatus(message);
@@ -818,6 +1051,24 @@ function itemListTitle(items, limit) {
return items.length > limit ? t("selectedItems", { name: shown, count: items.length }) : shown;
}
function renderPermissionItems(items) {
const names = items.length === 1 ? displayPath(items[0].path) :
items.map(displayName).join(", ");
const count = items.length > 1 ? t("permissionObjectCount", { count: items.length }) : "";
const namesEl = document.createElement("span");
namesEl.className = "permission-path-names";
namesEl.textContent = names;
permissionPathEl.innerHTML = "";
permissionPathEl.appendChild(namesEl);
if (count) {
const countEl = document.createElement("span");
countEl.className = "permission-path-count";
countEl.textContent = count;
permissionPathEl.appendChild(countEl);
}
permissionPathEl.title = count ? names + " " + count : names;
}
function compareText(a, b) {
return String(a || "").localeCompare(String(b || ""));
}
@@ -918,6 +1169,9 @@ function singleSelected() {
function updateButtons() {
const items = selectedEntries();
const locked = busy || contentEl.classList.contains("loading");
for (const button of spaceInfoEl.querySelectorAll(".space-item")) {
button.disabled = spaceNavigationLocked();
}
document.getElementById("copyBtn").disabled = locked || items.length === 0;
document.getElementById("moveBtn").disabled = locked || items.length === 0;
document.getElementById("renameBtn").disabled = locked || items.length !== 1;
@@ -929,8 +1183,11 @@ function updateButtons() {
uploadFolderBtn.disabled = locked;
document.getElementById("mkdirBtn").disabled = locked;
newTextBtn.disabled = locked;
for (const button of filesEl.querySelectorAll(".row-action, .mode-action")) button.disabled = locked;
for (const checkbox of filesEl.querySelectorAll(".select-cell input")) checkbox.disabled = locked;
selectAllEl.disabled = locked;
parentBtn.disabled = cwd === "/";
parentBtn.disabled = locked || cwd === "/";
exitBtn.disabled = locked;
selectAllEl.checked = entries.length > 0 && items.length === entries.length;
selectAllEl.indeterminate = items.length > 0 && items.length < entries.length;
renderInstallPkgButton(items, locked);
@@ -1115,6 +1372,25 @@ function render() {
nameBtn.appendChild(nameText);
nameTd.appendChild(nameBtn);
const modeTd = document.createElement("td");
modeTd.className = "mode-col";
if (!isParent) {
const modeBtn = document.createElement("button");
modeBtn.className = "mode-action";
modeBtn.type = "button";
modeBtn.textContent = permissionModeText(item.mode);
modeBtn.title = t("permissionChangeTitle", { name: displayName(item) });
modeBtn.setAttribute("aria-label", modeBtn.title);
modeBtn.disabled = busy || Boolean(loadingPath);
modeBtn.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
focusPath(item.path);
openPermissionDialog(item);
});
modeTd.appendChild(modeBtn);
}
appendChildren(
tr,
selectTd,
@@ -1122,7 +1398,7 @@ function render() {
classCell("type-col", itemTypeLabel(item)),
classCell("size-col", formatSize(item.size, item.type)),
classCell("time-col", formatTime(item.mtime)),
classCell("mode-col", isParent ? "" : "0" + Number(item.mode).toString(8))
modeTd
);
fragment.appendChild(tr);
}
@@ -1143,7 +1419,11 @@ function actionParentDirectory(event) {
event.stopPropagation();
}
if (busy || loadingPath || cwd === "/") return;
load(parentPath(cwd), undefined, false, true);
if (window.history && history.back && historyPath() === cwd) {
history.back();
} else {
load(parentPath(cwd), undefined, false, true, "push");
}
}
function cell(text) {
@@ -1159,10 +1439,11 @@ function classCell(className, text) {
return td;
}
async function load(path, scrollTop, force, alertOnError) {
async function load(path, scrollTop, force, alertOnError, historyMode) {
const shouldScrollTop = scrollTop !== false;
if ((busy && !force) || loadingPath) return;
loadingPath = path;
updateButtons();
startContentLoadingTimer();
try {
setStatus(t("readDir"));
@@ -1172,8 +1453,9 @@ async function load(path, scrollTop, force, alertOnError) {
}
if (!contentLoadingEl.hidden) await nextPaint();
cwd = data.path;
pathEl.textContent = displayPath(cwd);
renderAddressPath();
savePath(cwd);
if (historyMode) writeHistoryPath(cwd, historyMode === "replace");
refreshSpaces();
entries = data.entries.sort(compareEntries);
selected.clear();
@@ -1192,6 +1474,12 @@ async function load(path, scrollTop, force, alertOnError) {
}
}
async function loadAndReveal(path, scrollTop, force, alertOnError, historyMode, revealPath) {
const ok = await load(path, scrollTop, force, alertOnError, historyMode);
if (ok && revealPath) revealPathInList(revealPath);
return ok;
}
async function runAction(label, fn, options) {
try {
taskRefreshPath = cwd;
@@ -1456,7 +1744,8 @@ function renderTasks(tasks) {
const speed = Number(task.speed || 0);
const isDelete = task.op === "delete";
const isDownload = task.op === "download";
const isPreparing = (task.op === "copy" || task.op === "move") &&
const isChmod = task.op === "chmod";
const isPreparing = (task.op === "copy" || task.op === "move" || isChmod) &&
task.state === "running" && done === 0;
const isFinishing = !isDelete && !isDownload && task.state === "running" && total > 0 && done >= total;
const pct = total > 0 ? Math.min(100, Math.floor(done * 100 / total)) : 0;
@@ -1485,9 +1774,11 @@ function renderTasks(tasks) {
const meta = document.createElement("div");
meta.className = "task-meta";
const speedItem = document.createElement("div");
speedItem.textContent = t("speedLabel") + ": " + formatSpeed(speed);
speedItem.textContent = t("speedLabel") + ": " + (isChmod ?
t("itemsPerSecond", { count: Math.round(speed) }) : formatSpeed(speed));
const progressItem = document.createElement("div");
progressItem.textContent = t("progressLabel") + ": " + formatSize(done) + " / " + formatSize(total);
progressItem.textContent = t("progressLabel") + ": " + (isChmod ?
t("permissionProgress", { done, total }) : formatSize(done) + " / " + formatSize(total));
const etaItem = document.createElement("div");
etaItem.textContent = t("etaLabel") + ": " + averageEta(task, done, total);
appendChildren(meta, speedItem, progressItem, etaItem);
@@ -1512,8 +1803,11 @@ function renderTasks(tasks) {
if (confirm(t("cancelTaskConfirm", { label: opLabel(task.op) }))) {
requestTaskCancel(task.id);
api("/api/cancel", { id: task.id }).then(() => {
if (task.op === "upload" && uploadXhr) uploadXhr.abort();
return pollTasks();
if (task.op !== "upload") return pollTasks();
if (uploadXhr) uploadXhr.abort();
return api("/api/upload/finish", { task_id: task.id })
.catch(() => {})
.then(pollTasks);
}).catch(err => {
if (trackedTask && trackedTask.id === task.id) trackedTask.cancelRequested = false;
setStatus(t("cancelFailed", { error: err.message }));
@@ -1655,7 +1949,7 @@ function uploadConflicts(rels) {
return conflicts;
}
function uploadFileRequest(taskId, file, rel, overwrite) {
function uploadFileRequest(taskId, file, rel, overwrite, index) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
uploadXhr = xhr;
@@ -1681,12 +1975,25 @@ function uploadFileRequest(taskId, file, rel, overwrite) {
};
xhr.onerror = () => {
uploadXhr = null;
reject(new Error(xhr.statusText || t("backendError")));
const error = new Error(xhr.statusText || t("backendError"));
api("/api/tasks").then(data => {
const task = (data.tasks || []).find(item => item.id === taskId);
if (task && Number(task.completed_count || 0) >= index) {
resolve();
} else if (task && task.state === "failed") {
reject(new Error(backendErrorText(task.error_code, task.error_arg,
task.error || task.current)));
} else {
reject(error);
}
}).catch(() => reject(error));
};
xhr.onabort = () => {
uploadXhr = null;
const terminal = uploadTerminalAbort;
uploadTerminalAbort = false;
const err = new Error("aborted");
err.name = "AbortError";
err.name = terminal ? "TerminalTaskAbort" : "AbortError";
reject(err);
};
xhr.send(file);
@@ -1744,21 +2051,32 @@ async function uploadFiles(files) {
const file = list[i];
const rel = rels[i];
setStatus(t("uploadingStatus", { index: i + 1, count: list.length, name: rel }));
await uploadFileRequest(taskId, file, rel, overwrite);
await uploadFileRequest(taskId, file, rel, overwrite, i + 1);
}
await api("/api/upload/finish", { task_id: taskId });
await pollTasks();
setStatus(t("uploadDone", { count: list.length }));
} catch (err) {
const terminalAbort = err && err.name === "TerminalTaskAbort";
const canceled = err && err.name === "AbortError";
if (canceled) {
setStatus(t("taskCanceled", { label: opLabel("upload") }));
} else {
const message = t("uploadFailed", { error: err.message });
setStatus(message);
alert(message);
if (!terminalAbort) {
if (taskId) {
try {
await api("/api/cancel", { id: taskId });
await api("/api/upload/finish", { task_id: taskId });
} catch (cancelErr) {
/* The server may already have moved a failed upload to a terminal state. */
}
}
await pollTasks();
if (canceled) {
setStatus(t("taskCanceled", { label: opLabel("upload") }));
} else {
const message = t("uploadFailed", { error: err.message });
setStatus(message);
alert(message);
}
}
await pollTasks();
} finally {
uploadXhr = null;
uploadFilesEl.value = "";
@@ -1820,6 +2138,34 @@ textEditorCloseBtn.addEventListener("click", requestCloseTextEditor);
textEditorSaveBtn.addEventListener("click", saveTextEditor);
newTextBtn.addEventListener("click", actionNewText);
imagePreviewCloseBtn.addEventListener("click", closeImagePreview);
permissionCancelBtn.addEventListener("click", requestClosePermissionDialog);
permissionApplyBtn.addEventListener("click", applyPermissionMode);
permissionModeEl.addEventListener("input", () => {
if (validPermissionMode(permissionModeEl.value)) syncPermissionChecks(permissionModeEl.value);
});
permissionModeEl.addEventListener("change", validatePermissionMode);
for (const checkbox of permissionChecks) checkbox.addEventListener("change", syncPermissionMode);
window.addEventListener("resize", renderAddressPath);
window.addEventListener("popstate", event => {
if (historyBlocked()) {
writeHistoryPath(cwd, false);
if (!textEditorOverlayEl.hidden) {
requestCloseTextEditor();
return;
}
if (!permissionOverlayEl.hidden) {
requestClosePermissionDialog();
return;
}
setStatus(t("activeTask"));
return;
}
const path = event.state && event.state.path ? event.state.path : historyPath();
if (path && path !== cwd) {
const revealPath = parentPath(cwd) === path ? cwd : "";
loadAndReveal(path, false, false, true, null, revealPath);
}
});
document.addEventListener("click", event => {
if (!uploadMenuEl || uploadMenuEl.contains(event.target)) return;
uploadMenuEl.classList.remove("open");
@@ -1837,6 +2183,9 @@ textEditorOverlayEl.addEventListener("click", event => {
imagePreviewOverlayEl.addEventListener("click", event => {
if (event.target === imagePreviewOverlayEl) closeImagePreview();
});
permissionOverlayEl.addEventListener("click", event => {
if (event.target === permissionOverlayEl) requestClosePermissionDialog();
});
contentEl.addEventListener("click", event => {
if (!busy && !loadingPath) return;
event.preventDefault();
@@ -1874,8 +2223,8 @@ filesEl.addEventListener("click", event => {
if (item.type === "parent" || item.type === "d") {
const revealPath = item.type === "parent" ? cwd : "";
setTimeout(async () => {
if (await load(item.path, item.type === "parent" ? false : undefined, false, true)) {
/* if (revealPath) revealPathInList(revealPath); */
if (await load(item.path, item.type === "parent" ? false : undefined, false, true, "push")) {
if (revealPath) revealPathInList(revealPath);
} else await load(cwd, false, true);
}, 0);
}
@@ -1918,9 +2267,9 @@ async function init() {
applyStaticText();
readSavedSort();
updateSortHeaders();
const savedPath = readSavedPath();
const savedPath = historyPath() || readSavedPath();
cwd = savedPath;
pathEl.textContent = displayPath(cwd);
renderAddressPath();
taskRefreshPath = savedPath;
refreshSpaces();
await pollTasks();
@@ -1928,7 +2277,11 @@ async function init() {
setStatus(t("activeTask"));
return;
}
if (!(await load(savedPath || "/")) && savedPath !== "/") await load("/");
if (await load(savedPath || "/", undefined, false, false)) {
seedHistoryPath(cwd);
} else if (savedPath !== "/" && await load("/", undefined, false, false)) {
seedHistoryPath(cwd);
}
}
init();
+27 -20
View File
@@ -16,6 +16,7 @@
#include "websrv.h"
#define DOWNLOAD_ARCHIVE_NAME_LIMIT 20
#define DOWNLOAD_BUFFER_SIZE (2 * 1024 * 1024)
typedef struct tar_frame {
char path[PATH_MAX];
@@ -47,7 +48,7 @@ typedef struct tar_stream {
typedef struct download_file_stream {
file_task_t *task;
FILE *file;
int fd;
unsigned long long size;
unsigned long long sent;
int done;
@@ -295,6 +296,8 @@ static ssize_t
tar_read(void *cls, uint64_t pos, char *buf, size_t max) {
tar_stream_t *s = cls;
size_t out = 0;
unsigned long long progress = 0;
char progress_current[PATH_MAX] = {0};
(void)pos;
while(out < max && !s->done) {
@@ -340,10 +343,10 @@ tar_read(void *cls, uint64_t pos, char *buf, size_t max) {
}
out += (size_t)n;
s->file_remaining -= (unsigned long long)n;
if(s->task) {
task_update(s->task, TASK_RUNNING,
s->current_file[0] ? s->current_file : NULL,
(unsigned long long)n, NULL);
progress += (unsigned long long)n;
if(s->current_file[0]) {
snprintf(progress_current, sizeof(progress_current), "%s",
s->current_file);
}
if(!s->file_remaining) {
close(s->fd);
@@ -354,6 +357,11 @@ tar_read(void *cls, uint64_t pos, char *buf, size_t max) {
}
}
}
if(s->task && progress) {
task_update(s->task, TASK_RUNNING,
progress_current[0] ? progress_current : NULL,
progress, NULL);
}
return out ? (ssize_t)out : MHD_CONTENT_READER_END_OF_STREAM;
}
@@ -589,22 +597,18 @@ api_download_prepare(struct MHD_Connection *conn, const char *body,
static ssize_t
download_file_read(void *cls, uint64_t pos, char *buf, size_t max) {
download_file_stream_t *s = cls;
size_t len;
(void)pos;
ssize_t len;
if(task_cancel_requested(s->task)) {
s->error = ECANCELED;
return MHD_CONTENT_READER_END_WITH_ERROR;
}
if(fseek(s->file, (long)pos, SEEK_SET)) {
len = pread(s->fd, buf, max, (off_t)pos);
if(len < 0) {
s->error = errno ? errno : EIO;
return MHD_CONTENT_READER_END_WITH_ERROR;
}
if(!(len = fread(buf, 1, max, s->file))) {
if(ferror(s->file)) {
s->error = errno ? errno : EIO;
return MHD_CONTENT_READER_END_WITH_ERROR;
}
if(!len) {
s->done = 1;
return MHD_CONTENT_READER_END_OF_STREAM;
}
@@ -619,7 +623,7 @@ download_file_read(void *cls, uint64_t pos, char *buf, size_t max) {
}
task_update(s->task, TASK_RUNNING, s->task->src, add, NULL);
}
return (ssize_t)len;
return len;
}
static void
@@ -630,8 +634,8 @@ download_file_close(void *cls) {
if(!s) {
return;
}
if(s->file) {
fclose(s->file);
if(s->fd >= 0) {
close(s->fd);
}
canceled = task_cancel_requested(s->task);
if(canceled) {
@@ -692,15 +696,17 @@ api_download(struct MHD_Connection *conn) {
return send_json_error(conn, MHD_HTTP_INTERNAL_SERVER_ERROR, "out of memory");
}
file_stream->task = task;
file_stream->fd = -1;
file_stream->size = (unsigned long long)st.st_size;
file_stream->done = file_stream->size == 0;
file_stream->file = fopen(paths[0], "rb");
if(!file_stream->file) {
file_stream->fd = open(paths[0], O_RDONLY);
if(file_stream->fd < 0) {
free(file_stream);
task_update(task, TASK_FAILED, task->src, 0, strerror(errno));
return send_json_error(conn, MHD_HTTP_NOT_FOUND, "file not found");
}
resp = MHD_create_response_from_callback((uint64_t)st.st_size, 32 * 0x4000,
resp = MHD_create_response_from_callback((uint64_t)st.st_size,
DOWNLOAD_BUFFER_SIZE,
download_file_read, file_stream,
download_file_close);
if(!resp) {
@@ -738,7 +744,8 @@ api_download(struct MHD_Connection *conn) {
return send_json_error(conn, MHD_HTTP_INTERNAL_SERVER_ERROR, "out of memory");
}
}
resp = MHD_create_response_from_callback(MHD_SIZE_UNKNOWN, 32 * 0x4000,
resp = MHD_create_response_from_callback(MHD_SIZE_UNKNOWN,
DOWNLOAD_BUFFER_SIZE,
tar_read, stream, tar_close);
if(!resp) {
tar_close(stream);
+150 -7
View File
@@ -255,6 +255,8 @@ send_json_error_detail(struct MHD_Connection *conn, unsigned int status,
static int task_target_path(file_task_t *task, const char *src,
char *out, size_t size);
static int chmod_task_path(file_task_t *task, const char *path,
unsigned int mode, int recursive);
static int count_path_bytes_sync(file_task_t *task, const char *path,
const char *display,
@@ -1679,6 +1681,37 @@ task_worker(void *arg) {
for(i = 0; i < task->src_count && !ret; i++) {
ret = remove_path(task, task->srcs[i]);
}
} else if(task->op == TASK_CHMOD) {
unsigned long long ignored_bytes = 0;
size_t i;
ret = 0;
if(task->recursive) {
for(i = 0; i < task->src_count; i++) {
size_t files_before = task->file_count;
size_t dirs_before = task->dir_count;
if(count_task_path_bytes(task, task->srcs[i], task->srcs[i],
&ignored_bytes, &task->file_count,
&task->dir_count)) {
ret = -1;
break;
}
if(files_before == task->file_count && dirs_before == task->dir_count) {
task->file_count++;
}
}
total = task->file_count + task->dir_count;
} else {
total = task->src_count;
}
if(!ret) {
task_set_total(task, total);
for(i = 0; i < task->src_count && !ret; i++) {
ret = chmod_task_path(task, task->srcs[i], task->chmod_mode,
task->recursive);
}
}
}
if(ret) {
@@ -1686,6 +1719,10 @@ task_worker(void *arg) {
task_update(task, TASK_CANCELED, task->current[0] ? task->current : task->src,
0, "canceled");
} else {
if(task->op == TASK_CHMOD) {
task_set_error_code(task, "chmod_failed",
task->current[0] ? task->current : task->src);
}
task_update(task, TASK_FAILED, task->current[0] ? task->current : task->src,
0, strerror(errno));
}
@@ -1706,8 +1743,8 @@ task_worker(void *arg) {
static enum MHD_Result
create_task_response(struct MHD_Connection *conn, task_op_t op,
char **srcs, size_t src_count,
const char *dst, int overwrite) {
char **srcs, size_t src_count, const char *dst,
int overwrite, unsigned int chmod_mode, int recursive) {
file_task_t *task = calloc(1, sizeof(file_task_t));
strbuf_t b = {0};
struct stat st;
@@ -1745,6 +1782,8 @@ create_task_response(struct MHD_Connection *conn, task_op_t op,
task->op = op;
task->state = TASK_QUEUED;
task->chmod_mode = chmod_mode;
task->recursive = recursive;
task->srcs = srcs;
task->src_count = src_count;
snprintf(task->src, sizeof(task->src), "%s%s",
@@ -1814,8 +1853,9 @@ api_tasks(struct MHD_Connection *conn) {
json_escape(&b, task->error_code);
strbuf_append(&b, ",\"error_arg\":");
json_escape(&b, task->error_arg);
strbuf_printf(&b, ",\"src_count\":%zu,\"total\":%llu,\"done\":%llu,\"speed\":%llu,\"eta\":%llu,\"cancel_requested\":%s,\"created_at\":%lld,\"elapsed\":%lld,\"total_elapsed\":%lld,\"updated_at\":%lld}",
task->src_count, task->total, task->done, task->speed, task->eta,
strbuf_printf(&b, ",\"src_count\":%zu,\"completed_count\":%zu,\"total\":%llu,\"done\":%llu,\"speed\":%llu,\"eta\":%llu,\"cancel_requested\":%s,\"created_at\":%lld,\"elapsed\":%lld,\"total_elapsed\":%lld,\"updated_at\":%lld}",
task->src_count, task->upload_completed,
task->total, task->done, task->speed, task->eta,
task->cancel_requested ? "true" : "false",
(long long)task->created_at,
task->transfer_started_at ? (long long)(now - task->transfer_started_at) : 0LL,
@@ -1907,7 +1947,7 @@ api_copy(struct MHD_Connection *conn, const char *body, size_t body_size) {
}
}
ret = create_task_response(conn, TASK_COPY, paths, count, dst,
overwrite && !strcmp(overwrite, "1"));
overwrite && !strcmp(overwrite, "1"), 0, 0);
free(paths_raw); free(dst); free(overwrite);
return ret;
}
@@ -1926,7 +1966,7 @@ api_move(struct MHD_Connection *conn, const char *body, size_t body_size) {
return send_json_error(conn, MHD_HTTP_BAD_REQUEST, NULL);
}
ret = create_task_response(conn, TASK_MOVE, paths, count, dst,
overwrite && !strcmp(overwrite, "1"));
overwrite && !strcmp(overwrite, "1"), 0, 0);
free(paths_raw); free(dst); free(overwrite);
return ret;
}
@@ -1948,7 +1988,7 @@ api_delete(struct MHD_Connection *conn, const char *body, size_t body_size) {
return send_json_error(conn, MHD_HTTP_BAD_REQUEST, "invalid path");
}
}
ret = create_task_response(conn, TASK_DELETE, paths, count, NULL, 0);
ret = create_task_response(conn, TASK_DELETE, paths, count, NULL, 0, 0, 0);
free(paths_raw);
return ret;
}
@@ -2009,6 +2049,104 @@ api_mkdir(struct MHD_Connection *conn) {
: send_json_ok(conn);
}
static int
parse_permission_mode(const char *text, unsigned int *mode) {
size_t i;
if(!text || strlen(text) != 4 || text[0] != '0') {
return -1;
}
for(i = 1; i < 4; i++) {
if(text[i] < '0' || text[i] > '7') {
return -1;
}
}
*mode = (unsigned int)((text[1] - '0') << 6) |
(unsigned int)((text[2] - '0') << 3) |
(unsigned int)(text[3] - '0');
return 0;
}
static int
chmod_task_path(file_task_t *task, const char *path,
unsigned int mode, int recursive) {
struct stat st;
if(task_cancel_requested(task)) {
return -1;
}
task_update(task, TASK_RUNNING, path, 0, NULL);
if(lstat(path, &st)) {
return -1;
}
if(recursive && S_ISDIR(st.st_mode)) {
DIR *dir = opendir(path);
struct dirent *entry;
if(!dir) {
return -1;
}
while((entry = readdir(dir))) {
char child[PATH_MAX];
int error;
if(!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) {
continue;
}
if(task_cancel_requested(task) ||
path_join(child, sizeof(child), path, entry->d_name) ||
lstat(child, &st)) {
error = errno;
closedir(dir);
errno = error;
return -1;
}
/* A symlink has no portable mode of its own; never change its target. */
if(!S_ISLNK(st.st_mode) && chmod_task_path(task, child, mode, 1)) {
error = errno;
closedir(dir);
errno = error;
return -1;
}
}
closedir(dir);
}
task_update(task, TASK_RUNNING, path, 0, NULL);
if(chmod_path_mode(path, mode)) {
return -1;
}
task_update(task, TASK_RUNNING, path, 1, NULL);
return 0;
}
static enum MHD_Result
api_chmod(struct MHD_Connection *conn, const char *body, size_t body_size) {
char *paths_raw = body_form_value(body, body_size, "paths");
char *mode_text = body_form_value(body, body_size, "mode");
char *recursive_text = body_form_value(body, body_size, "recursive");
char **paths = NULL;
size_t count = 0;
unsigned int mode;
enum MHD_Result ret;
if(!paths_raw || parse_paths(paths_raw, &paths, &count)) {
free(paths_raw); free(mode_text); free(recursive_text);
free_paths(paths, count);
return send_json_error(conn, MHD_HTTP_BAD_REQUEST, "invalid path");
}
if(parse_permission_mode(mode_text, &mode)) {
free(paths_raw); free(mode_text); free(recursive_text);
free_paths(paths, count);
return send_json_error_detail(conn, MHD_HTTP_BAD_REQUEST,
"invalid permission mode",
"invalid_mode", NULL);
}
ret = create_task_response(conn, TASK_CHMOD, paths, count, NULL, 0, mode,
recursive_text && !strcmp(recursive_text, "1"));
free(paths_raw); free(mode_text); free(recursive_text);
return ret;
}
static enum MHD_Result
api_install_pkg(struct MHD_Connection *conn) {
char *path = fs_path_value(query_value(conn, "path"));
@@ -2066,6 +2204,11 @@ filemgr_api_request(struct MHD_Connection *conn, const char *url,
if(!strcmp(url, "/api/upload/finish")) return api_upload_finish(conn);
if(!strcmp(url, "/api/rename")) return api_rename(conn);
if(!strcmp(url, "/api/mkdir")) return api_mkdir(conn);
if(!strcmp(url, "/api/chmod")) {
return strcmp(method, MHD_HTTP_METHOD_POST) ?
send_json_error(conn, MHD_HTTP_METHOD_NOT_ALLOWED, "invalid method") :
api_chmod(conn, body, body_size);
}
if(!strcmp(url, "/api/install-pkg")) {
return strcmp(method, MHD_HTTP_METHOD_POST) ?
send_json_error(conn, MHD_HTTP_METHOD_NOT_ALLOWED, "invalid method") :
+6
View File
@@ -13,6 +13,7 @@ typedef enum task_op {
TASK_COPY,
TASK_MOVE,
TASK_DELETE,
TASK_CHMOD,
TASK_DOWNLOAD,
TASK_UPLOAD,
} task_op_t;
@@ -44,6 +45,9 @@ typedef struct file_task {
size_t src_count;
size_t file_count;
size_t dir_count;
size_t upload_completed;
unsigned int chmod_mode;
int recursive;
unsigned long long total;
unsigned long long done;
unsigned long long speed;
@@ -54,6 +58,7 @@ typedef struct file_task {
unsigned int eta_sample_next;
unsigned int eta_sample_count;
int cancel_requested;
unsigned int active_streams;
time_t created_at;
time_t transfer_started_at;
time_t updated_at;
@@ -88,6 +93,7 @@ enum MHD_Result send_buffer(struct MHD_Connection *conn, unsigned int status,
char *data, const char *mime);
int ensure_parent_dirs(const char *base, const char *rel);
int chmod_path_mode(const char *path, unsigned int mode);
int chmod_path_0777(const char *path);
int fchmod_0777(int fd);
int ignore_chmod_error(int err);
+15
View File
@@ -65,6 +65,21 @@ fd_has_unix_modes(int fd) {
#endif
}
int
chmod_path_mode(const char *path, unsigned int mode) {
if(!path_has_unix_modes(path)) {
return 0;
}
if(chmod(path, (mode_t)(mode & 0777))) {
/* A filesystem that does not implement Unix modes is compatible with the
paste behavior. Real permission and read-only errors must reach the UI. */
if(errno != ENOTSUP && errno != EINVAL) {
return -1;
}
}
return 0;
}
int
chmod_path_0777(const char *path) {
if(!path_has_unix_modes(path)) {
+15 -4
View File
@@ -4,6 +4,7 @@
#include <limits.h>
#include <pthread.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
@@ -14,8 +15,13 @@ typedef struct pkg_metadata {
const char *content_id;
const char *content_name;
const char *icon_url;
uint32_t slot;
uint32_t is_playgo_enabled;
} pkg_metadata_t;
_Static_assert(sizeof(pkg_metadata_t) == 0x38,
"sceAppInstUtil metadata ABI mismatch");
typedef struct pkg_info {
char content_id[48];
int type;
@@ -23,12 +29,15 @@ typedef struct pkg_info {
} pkg_info_t;
typedef struct playgo_info {
char lang[8][30];
char scenario_ids[3][64];
char content_ids[64];
char languages[30][8];
char scenario_ids[64][3];
char content_ids[64][48];
long unknown[810];
} playgo_info_t;
_Static_assert(sizeof(playgo_info_t) == 0x2700,
"sceAppInstUtil PlayGoInfo ABI mismatch");
int sceAppInstUtilInitialize(void);
int sceAppInstUtilInstallByPackage(const pkg_metadata_t *, pkg_info_t *,
playgo_info_t *);
@@ -68,7 +77,9 @@ pkg_installer_install(const char *path) {
.playgo_scenario_id = "",
.content_id = "",
.content_name = "",
.icon_url = ""
.icon_url = "",
.slot = 0,
.is_playgo_enabled = 0
};
pkg_info_t pkg_info = {0};
playgo_info_t playgo_info = {0};
+2 -1
View File
@@ -20,6 +20,7 @@ task_op_name(task_op_t op) {
case TASK_COPY: return "copy";
case TASK_MOVE: return "move";
case TASK_DELETE: return "delete";
case TASK_CHMOD: return "chmod";
case TASK_DOWNLOAD: return "download";
case TASK_UPLOAD: return "upload";
default: return "unknown";
@@ -81,7 +82,7 @@ remove_finished_tasks_locked(void) {
while(*link) {
file_task_t *task = *link;
if(task_is_active(task)) {
if(task_is_active(task) || task->active_streams) {
link = &task->next;
continue;
}
+142 -40
View File
@@ -14,9 +14,13 @@
#include "json_util.h"
#include "path_util.h"
#define UPLOAD_BUFFER_SIZE (1024 * 1024)
typedef struct upload_context {
file_task_t *task;
int fd;
char *buffer;
size_t buffered;
char temp[PATH_MAX];
char target[PATH_MAX];
unsigned long long expected;
@@ -24,8 +28,51 @@ typedef struct upload_context {
int failed;
int error;
int task_done;
const char *stage;
char error_message[256];
} upload_context_t;
static const char *
upload_error_message(upload_context_t *ctx) {
if(!ctx->error_message[0]) {
snprintf(ctx->error_message, sizeof(ctx->error_message), "%s failed%s%s: %s",
ctx->stage ? ctx->stage : "upload",
ctx->target[0] ? " for " : "",
ctx->target[0] ? ctx->target : "",
strerror(ctx->error ? ctx->error : EIO));
}
return ctx->error_message;
}
static int
upload_flush(upload_context_t *ctx) {
size_t written = 0;
while(written < ctx->buffered) {
ssize_t n;
if(ctx->task && task_cancel_requested(ctx->task)) {
ctx->failed = 1;
ctx->error = ECANCELED;
return -1;
}
n = write(ctx->fd, ctx->buffer + written, ctx->buffered - written);
if(n <= 0) {
ctx->failed = 1;
ctx->error = errno ? errno : EIO;
return -1;
}
written += (size_t)n;
ctx->written += (unsigned long long)n;
}
if(ctx->task && written) {
task_update(ctx->task, TASK_RUNNING, ctx->target,
(unsigned long long)written, NULL);
}
ctx->buffered = 0;
return 0;
}
static void
finish_upload_task(file_task_t *task, task_state_t state, const char *current,
const char *error) {
@@ -48,11 +95,29 @@ finish_upload_task(file_task_t *task, task_state_t state, const char *current,
if(error) {
snprintf(task->error, sizeof(task->error), "%s", error);
}
if(state == TASK_FAILED) {
snprintf(task->error_code, sizeof(task->error_code), "upload_failed");
snprintf(task->error_arg, sizeof(task->error_arg), "%s",
current ? current : task->src);
}
task->updated_at = completed_at;
}
pthread_mutex_unlock(&g_tasks_lock);
}
static void
finish_upload_context_error(upload_context_t *ctx) {
if(!ctx->task || ctx->task_done) {
return;
}
upload_error_message(ctx);
finish_upload_task(ctx->task,
ctx->error == ECANCELED ? TASK_CANCELED : TASK_FAILED,
ctx->target[0] ? ctx->target : ctx->task->current,
ctx->error == ECANCELED ? "canceled" : ctx->error_message);
ctx->task_done = 1;
}
static file_task_t *
upload_task_from_conn(struct MHD_Connection *conn) {
char *idstr = request_value(conn, "X-WFM-Task-ID", "task_id");
@@ -267,12 +332,18 @@ filemgr_upload_begin(struct MHD_Connection *conn, void **upload_ctx) {
return -1;
}
ctx->fd = -1;
ctx->stage = "preparing upload";
*upload_ctx = ctx;
if(size_text) {
ctx->expected = strtoull(size_text, NULL, 10);
}
ctx->task = task;
if(task) {
pthread_mutex_lock(&g_tasks_lock);
task->active_streams++;
pthread_mutex_unlock(&g_tasks_lock);
}
if(task && task_cancel_requested(task)) {
ctx->failed = 1;
ctx->error = ECANCELED;
@@ -283,17 +354,20 @@ filemgr_upload_begin(struct MHD_Connection *conn, void **upload_ctx) {
ctx->error = EBUSY;
goto fail;
}
ctx->stage = "validating target path";
if(!base || !rel || path_join_relative(ctx->target, sizeof(ctx->target),
base, rel)) {
ctx->failed = 1;
ctx->error = errno ? errno : EINVAL;
goto fail;
}
ctx->stage = "creating target folders";
if(ensure_parent_dirs(base, rel)) {
ctx->failed = 1;
ctx->error = errno ? errno : EACCES;
goto fail;
}
ctx->stage = "checking target path";
if(!lstat(ctx->target, &st)) {
if(S_ISDIR(st.st_mode) || !overwrite || strcmp(overwrite, "1")) {
ctx->failed = 1;
@@ -305,19 +379,26 @@ filemgr_upload_begin(struct MHD_Connection *conn, void **upload_ctx) {
ctx->error = errno;
goto fail;
}
ctx->stage = "checking target permissions";
if(check_target_writable(ctx->target, &checked_dirs, &checked_dir_count,
error, sizeof(error), code, sizeof(code),
arg, sizeof(arg))) {
snprintf(ctx->error_message, sizeof(ctx->error_message), "%s",
error[0] ? error : "target is not writable");
ctx->failed = 1;
ctx->error = errno ? errno : EACCES;
goto fail;
}
ctx->stage = "checking target space";
if(check_target_space(ctx->target, ctx->expected, error, sizeof(error),
code, sizeof(code), arg, sizeof(arg))) {
snprintf(ctx->error_message, sizeof(ctx->error_message), "%s",
error[0] ? error : "not enough target space");
ctx->failed = 1;
ctx->error = errno ? errno : ENOSPC;
goto fail;
}
ctx->stage = "creating temporary path";
n = snprintf(ctx->temp, sizeof(ctx->temp), "%s.wfm-upload-%ld-%lld.tmp",
ctx->target, (long)getpid(), (long long)time(NULL));
if(n < 0 || (size_t)n >= sizeof(ctx->temp)) {
@@ -325,24 +406,25 @@ filemgr_upload_begin(struct MHD_Connection *conn, void **upload_ctx) {
ctx->error = ENAMETOOLONG;
goto fail;
}
ctx->stage = "opening temporary file";
ctx->fd = open(ctx->temp, O_WRONLY | O_CREAT | O_TRUNC, 0600);
if(ctx->fd < 0) {
ctx->failed = 1;
ctx->error = errno;
goto fail;
}
ctx->stage = "allocating upload buffer";
if(!(ctx->buffer = malloc(UPLOAD_BUFFER_SIZE))) {
ctx->failed = 1;
ctx->error = ENOMEM;
goto fail;
}
fail:
free_paths(checked_dirs, checked_dir_count);
free(base); free(rel); free(overwrite); free(size_text);
if(ctx->failed) {
if(ctx->task) {
finish_upload_task(ctx->task,
ctx->error == ECANCELED ? TASK_CANCELED : TASK_FAILED,
ctx->target[0] ? ctx->target : ctx->task->current,
ctx->error == ECANCELED ? "canceled" : strerror(ctx->error ? ctx->error : EIO));
ctx->task_done = 1;
}
finish_upload_context_error(ctx);
if(ctx->fd >= 0) {
close(ctx->fd);
ctx->fd = -1;
@@ -353,13 +435,13 @@ fail:
errno = ctx->error ? ctx->error : EIO;
return -1;
}
ctx->stage = "writing file";
return 0;
}
int
filemgr_upload_data(void *upload_ctx, const char *data, size_t size) {
upload_context_t *ctx = upload_ctx;
size_t written = 0;
if(!ctx || ctx->failed) {
return -1;
@@ -367,21 +449,21 @@ filemgr_upload_data(void *upload_ctx, const char *data, size_t size) {
if(ctx->task && task_cancel_requested(ctx->task)) {
ctx->failed = 1;
ctx->error = ECANCELED;
finish_upload_context_error(ctx);
return -1;
}
while(written < size) {
ssize_t n = write(ctx->fd, data + written, size - written);
if(n <= 0) {
ctx->failed = 1;
ctx->error = errno ? errno : EIO;
while(size) {
size_t space = UPLOAD_BUFFER_SIZE - ctx->buffered;
size_t take = size < space ? size : space;
memcpy(ctx->buffer + ctx->buffered, data, take);
ctx->buffered += take;
data += take;
size -= take;
if(ctx->buffered == UPLOAD_BUFFER_SIZE && upload_flush(ctx)) {
finish_upload_context_error(ctx);
return -1;
}
written += (size_t)n;
ctx->written += (unsigned long long)n;
if(ctx->task) {
task_update(ctx->task, TASK_RUNNING, ctx->target,
(unsigned long long)n, NULL);
}
}
return 0;
}
@@ -395,42 +477,55 @@ filemgr_upload_finish(struct MHD_Connection *conn, void *upload_ctx) {
return send_json_error(conn, MHD_HTTP_BAD_REQUEST, "invalid path");
}
if(ctx->failed) {
if(ctx->task && !ctx->task_done) {
finish_upload_task(ctx->task,
ctx->error == ECANCELED ? TASK_CANCELED : TASK_FAILED,
ctx->target[0] ? ctx->target : ctx->task->current,
ctx->error == ECANCELED ? "canceled" : strerror(ctx->error ? ctx->error : EIO));
ctx->task_done = 1;
}
finish_upload_context_error(ctx);
errno = ctx->error ? ctx->error : EIO;
return send_json_error(conn, errno == EEXIST ? MHD_HTTP_CONFLICT :
errno == EBUSY ? MHD_HTTP_CONFLICT :
errno == ECANCELED ? MHD_HTTP_CONFLICT :
errno == ENOSPC ? MHD_HTTP_INSUFFICIENT_STORAGE :
MHD_HTTP_INTERNAL_SERVER_ERROR, NULL);
return send_json_error_detail(conn,
errno == EEXIST ? MHD_HTTP_CONFLICT :
errno == EBUSY ? MHD_HTTP_CONFLICT :
errno == ECANCELED ? MHD_HTTP_CONFLICT :
errno == ENOSPC ? MHD_HTTP_INSUFFICIENT_STORAGE :
MHD_HTTP_INTERNAL_SERVER_ERROR,
ctx->error == ECANCELED ? "canceled" : ctx->error_message,
ctx->error == ECANCELED ? NULL : "upload_failed",
ctx->target[0] ? ctx->target : NULL);
}
ctx->stage = "writing file";
if(ctx->buffered && upload_flush(ctx)) {
ctx->error = ctx->error ? ctx->error : EIO;
goto done;
}
ctx->stage = "verifying uploaded size";
if(ctx->expected && ctx->written != ctx->expected) {
ctx->error = EIO;
goto done;
}
ctx->stage = "setting file permissions";
if(fchmod_0777(ctx->fd)) {
ctx->error = errno;
goto done;
}
ctx->stage = "syncing file data";
if(fsync(ctx->fd)) {
ctx->error = errno;
goto done;
}
ctx->stage = "closing temporary file";
if(close(ctx->fd)) {
ctx->fd = -1;
ctx->error = errno;
goto done;
}
ctx->fd = -1;
ctx->stage = "moving temporary file into place";
if(rename(ctx->temp, ctx->target)) {
ctx->error = errno;
goto done;
}
if(ctx->task) {
pthread_mutex_lock(&g_tasks_lock);
ctx->task->upload_completed++;
pthread_mutex_unlock(&g_tasks_lock);
}
ret = 0;
done:
@@ -439,19 +534,18 @@ done:
ctx->fd = -1;
}
if(ret) {
upload_error_message(ctx);
if(ctx->temp[0]) {
unlink(ctx->temp);
}
if(ctx->task && !ctx->task_done) {
finish_upload_task(ctx->task,
ctx->error == ECANCELED ? TASK_CANCELED : TASK_FAILED,
ctx->target[0] ? ctx->target : ctx->task->current,
ctx->error == ECANCELED ? "canceled" : strerror(ctx->error ? ctx->error : EIO));
ctx->task_done = 1;
}
finish_upload_context_error(ctx);
errno = ctx->error ? ctx->error : EIO;
return send_json_error(conn, errno == ECANCELED ? MHD_HTTP_CONFLICT :
MHD_HTTP_INTERNAL_SERVER_ERROR, NULL);
return send_json_error_detail(conn,
errno == ECANCELED ? MHD_HTTP_CONFLICT :
MHD_HTTP_INTERNAL_SERVER_ERROR,
ctx->error == ECANCELED ? "canceled" : ctx->error_message,
ctx->error == ECANCELED ? NULL : "upload_failed",
ctx->target[0] ? ctx->target : NULL);
}
return send_json_ok(conn);
}
@@ -476,5 +570,13 @@ filemgr_upload_free(void *upload_ctx) {
ctx->task_done = 1;
}
}
free(ctx->buffer);
if(ctx->task) {
pthread_mutex_lock(&g_tasks_lock);
if(ctx->task->active_streams) {
ctx->task->active_streams--;
}
pthread_mutex_unlock(&g_tasks_lock);
}
free(ctx);
}
+27 -1
View File
@@ -6,6 +6,7 @@
#include <arpa/inet.h>
#include <microhttpd.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <sys/socket.h>
#include <unistd.h>
@@ -14,10 +15,24 @@
#include "websrv.h"
#define REQUEST_BODY_MAX (4 * 1024 * 1024)
#define HTTP_CONNECTION_MEMORY_LIMIT (8 * 1024 * 1024)
#define HTTP_CONNECTION_MEMORY_INCREMENT (2 * 1024 * 1024)
#define HTTP_SOCKET_RCVBUF_SIZE (4 * 1024 * 1024)
#define HTTP_SOCKET_SNDBUF_SIZE (4 * 1024 * 1024)
static volatile sig_atomic_t g_stop_requested;
static int g_listen_fd = -1;
static void
websrv_tune_connection_socket(int fd) {
const int sndbuf = HTTP_SOCKET_SNDBUF_SIZE;
const int nodelay = 1;
/* OrbisOS HTTP sockets need an explicit send buffer to fill a GbE link. */
(void)setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &sndbuf, sizeof(sndbuf));
(void)setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &nodelay, sizeof(nodelay));
}
typedef struct request_context {
char *body;
size_t size;
@@ -190,6 +205,12 @@ websrv_listen(unsigned short port) {
close(srvfd);
return -1;
}
{
const int rcvbuf = HTTP_SOCKET_RCVBUF_SIZE;
/* Set before listen so accepted PS5 sockets inherit the larger window. */
(void)setsockopt(srvfd, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof(rcvbuf));
}
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
@@ -211,8 +232,12 @@ websrv_listen(unsigned short port) {
if(!(httpd = MHD_start_daemon(MHD_USE_THREAD_PER_CONNECTION | MHD_USE_ITC |
MHD_USE_NO_LISTEN_SOCKET | MHD_USE_DEBUG |
MHD_USE_INTERNAL_POLLING_THREAD,
MHD_USE_INTERNAL_POLLING_THREAD | MHD_USE_TURBO,
0, NULL, NULL, &websrv_on_request, NULL,
MHD_OPTION_CONNECTION_MEMORY_LIMIT,
(size_t)HTTP_CONNECTION_MEMORY_LIMIT,
MHD_OPTION_CONNECTION_MEMORY_INCREMENT,
(size_t)HTTP_CONNECTION_MEMORY_INCREMENT,
MHD_OPTION_NOTIFY_COMPLETED,
&websrv_on_completed, NULL, MHD_OPTION_END))) {
perror("MHD_start_daemon");
@@ -226,6 +251,7 @@ websrv_listen(unsigned short port) {
if(!g_stop_requested) perror("accept");
break;
}
websrv_tune_connection_socket(connfd);
if(MHD_add_connection(httpd, connfd, (struct sockaddr *)&client_addr,
addr_len) != MHD_YES) {
perror("MHD_add_connection");