Compare commits
9 Commits
e4930f448b
...
9d74839ec3
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d74839ec3 | |||
| df2d0f55b9 | |||
| 67b726e08c | |||
| a2ac0e393b | |||
| 329b0e3ce5 | |||
| 365ee2f596 | |||
| 994aaccac4 | |||
| ab1da7e319 | |||
| f1b7dd9bff |
@@ -1352,6 +1352,22 @@ champ — ça détruisait le focus sur les forms qui n'étaient pas centrés
|
|||||||
(hostname), mettre aussi `title = "Gitea"` (brand) couvre le browser
|
(hostname), mettre aussi `title = "Gitea"` (brand) couvre le browser
|
||||||
title sans ambiguïté
|
title sans ambiguïté
|
||||||
|
|
||||||
|
### Exclure une entrée : étiquette `no-autofill` (2026-09-18)
|
||||||
|
|
||||||
|
`AUTOFILL_SKIP_TAG` (js/app.autofill.js) retire l'entrée du pool **avant** le
|
||||||
|
scoring : raccourcis `Ctrl+Shift+L/P/T` et barrette l'ignorent. Une étiquette et
|
||||||
|
non une colonne : elle est déjà chiffrée, synchronisée, exportée et reprise par
|
||||||
|
la rotation — un champ de plus aurait demandé les 7 points de passage de
|
||||||
|
« Entry payload ». La recherche rapide `Ctrl+Shift+Q` garde ces entrées (choix
|
||||||
|
explicite, pas une correspondance devinée sur un titre).
|
||||||
|
|
||||||
|
**Idée gardée (rien n'est fait)** : une case « Ne pas proposer à l'autofill »
|
||||||
|
dans l'éditeur d'entrée (slideover), qui **ajoute ou retire la même étiquette**
|
||||||
|
— même stockage, mais plus besoin de connaître son nom exact. À brancher sur
|
||||||
|
`soDirtyCheck` (la case modifie les tags, donc l'entrée devient modifiée) et
|
||||||
|
sur la reconstruction des chips d'étiquettes pour que les deux restent
|
||||||
|
d'accord.
|
||||||
|
|
||||||
### Contraintes connues autofill
|
### Contraintes connues autofill
|
||||||
|
|
||||||
- **UIPI** : SendInput vers process élevé bloqué. Notepad++ admin,
|
- **UIPI** : SendInput vers process élevé bloqué. Notepad++ admin,
|
||||||
|
|||||||
@@ -140,7 +140,8 @@ begin
|
|||||||
LHttp.ResponseTimeout := FTimeoutMs;
|
LHttp.ResponseTimeout := FTimeoutMs;
|
||||||
LHttp.HandleRedirects := False;
|
LHttp.HandleRedirects := False;
|
||||||
LHttp.UserAgent := 'PMServer';
|
LHttp.UserAgent := 'PMServer';
|
||||||
LHttp.CustomHeaders['Authorization'] := 'token ' + FToken;
|
// SANS JETON, expres : "public" = visible par n'importe qui. Un jeton faux
|
||||||
|
// faisait repondre 401 avant tout verdict ; prive et anonyme = 404.
|
||||||
try
|
try
|
||||||
LResp := LHttp.Get(RepoApiUrl(FBaseUrl));
|
LResp := LHttp.Get(RepoApiUrl(FBaseUrl));
|
||||||
except
|
except
|
||||||
|
|||||||
@@ -323,6 +323,24 @@ secret (`hasTotp`, un booléen — jamais le secret dans la page). Le code est
|
|||||||
calculé à l'instant de la copie ; le message dit combien de secondes il lui
|
calculé à l'instant de la copie ; le message dit combien de secondes il lui
|
||||||
reste.
|
reste.
|
||||||
|
|
||||||
|
### Idées gardées pour plus tard (décidé le 2026-09-18)
|
||||||
|
|
||||||
|
**Rien n'est fait.** Deux propositions retenues pour copier encore plus vite :
|
||||||
|
|
||||||
|
- **Identifiant puis mot de passe, en un appui** (effort moyen). Un appui copie
|
||||||
|
l'identifiant ; au retour dans Vault après l'avoir collé (`BecameActive`), le
|
||||||
|
mot de passe de la même entrée est **déjà** dans le presse-papiers, avec un
|
||||||
|
message qui le dit. À borner : seulement si le retour a lieu dans la grâce de
|
||||||
|
2 minutes, et une seule fois — sinon un retour sans rapport recopierait un
|
||||||
|
mot de passe que personne n'a demandé.
|
||||||
|
- **Remplissage automatique Android** (gros chantier, plusieurs jours). Un
|
||||||
|
`AutofillService` comme Bitwarden : Android propose les comptes dans le champ
|
||||||
|
de connexion de n'importe quelle app ou de Chrome, sans copier-coller. Exige
|
||||||
|
une classe **Java** (Delphi ne sait pas l'écrire) compilée en `.jar` et
|
||||||
|
déclarée au manifeste avec `BIND_AUTOFILL_SERVICE`, et une authentification
|
||||||
|
(code, empreinte) **dans le service** avant de proposer quoi que ce soit : le
|
||||||
|
coffre ne vit que dans l'app, ouverte ou non.
|
||||||
|
|
||||||
## Saisie : ce qui a résisté
|
## Saisie : ce qui a résisté
|
||||||
|
|
||||||
- **`user-select: none` du `body`** était hérité par les champs : le curseur ne
|
- **`user-select: none` du `body`** était hérité par les champs : le curseur ne
|
||||||
|
|||||||
@@ -2885,6 +2885,7 @@ body[data-editor-position="center"]:has(#settingsPanel.is-open)::before {
|
|||||||
.col-actions { width: 100%; min-width: 80px; text-align: right; white-space: nowrap; }
|
.col-actions { width: 100%; min-width: 80px; text-align: right; white-space: nowrap; }
|
||||||
.col-actions .icon-btn { opacity: 0; transition: opacity var(--t-fast); }
|
.col-actions .icon-btn { opacity: 0; transition: opacity var(--t-fast); }
|
||||||
.entry-row:hover .col-actions .icon-btn { opacity: 0.7; }
|
.entry-row:hover .col-actions .icon-btn { opacity: 0.7; }
|
||||||
|
.col-actions .icon-btn.is-always { opacity: 0.7; }
|
||||||
.col-actions .entry-kebab-wrap { display: inline-block; vertical-align: middle; }
|
.col-actions .entry-kebab-wrap { display: inline-block; vertical-align: middle; }
|
||||||
|
|
||||||
/* Hide Edge/IE native password reveal eye (we ship our own toggle). */
|
/* Hide Edge/IE native password reveal eye (we ship our own toggle). */
|
||||||
|
|||||||
@@ -1659,7 +1659,12 @@ begin
|
|||||||
// "token <PAT>" et non "Bearer" : c'est la forme que Gitea attend
|
// "token <PAT>" et non "Bearer" : c'est la forme que Gitea attend
|
||||||
// d'un jeton d'acces personnel, et l'autre donne un 401 muet.
|
// d'un jeton d'acces personnel, et l'autre donne un 401 muet.
|
||||||
if LGitea then
|
if LGitea then
|
||||||
LHttp.CustomHeaders['Authorization'] := 'token ' + LPwd
|
begin
|
||||||
|
// Pas d'en-tete du tout sans jeton : la verification "depot
|
||||||
|
// public" se fait volontairement en anonyme.
|
||||||
|
if LPwd <> '' then
|
||||||
|
LHttp.CustomHeaders['Authorization'] := 'token ' + LPwd;
|
||||||
|
end
|
||||||
else if (LUser <> '') then
|
else if (LUser <> '') then
|
||||||
begin
|
begin
|
||||||
LHttp.CredentialsStorage.AddCredential(
|
LHttp.CredentialsStorage.AddCredential(
|
||||||
|
|||||||
+10
-9
@@ -841,12 +841,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="slideover-field" id="syncField" style="display:none">
|
<div class="slideover-field" id="syncField" style="display:none">
|
||||||
<div class="slideover-field-label">Sync (WebDAV or Gitea)</div>
|
<div class="slideover-field-label">Sync (WebDAV or Git repository)</div>
|
||||||
<p style="font-size:11px;color:var(--text-faint);margin:0 0 8px;line-height:1.5">
|
<p style="font-size:11px;color:var(--text-faint);margin:0 0 8px;line-height:1.5">
|
||||||
Bidirectional sync with a WebDAV server (Nextcloud,
|
Bidirectional sync with a WebDAV server (Nextcloud,
|
||||||
ownCloud, Apache mod_dav) <b>or a private Gitea
|
ownCloud, Apache mod_dav) <b>or a private Git
|
||||||
repository</b> — paste a contents-API URL and it switches
|
repository</b> (Gitea or GitHub — not GitLab) — paste the
|
||||||
transport on its own. Auto-merge by entry
|
repository address and it switches transport on its own. Auto-merge by entry
|
||||||
timestamp. <b>Master password is never sent to the
|
timestamp. <b>Master password is never sent to the
|
||||||
remote</b> — the snapshot is encrypted with a separate
|
remote</b> — the snapshot is encrypted with a separate
|
||||||
sync password you set once per device (same on each).
|
sync password you set once per device (same on each).
|
||||||
@@ -857,9 +857,9 @@
|
|||||||
</label>
|
</label>
|
||||||
<div id="syncConfig" style="display:none;border-left:2px solid var(--border);padding-left:10px;margin-left:4px">
|
<div id="syncConfig" style="display:none;border-left:2px solid var(--border);padding-left:10px;margin-left:4px">
|
||||||
<div style="margin-bottom:6px">
|
<div style="margin-bottom:6px">
|
||||||
<div style="font-size:11px;color:var(--text-faint);margin-bottom:4px">URL — full path to the snapshot file, or a Gitea contents path</div>
|
<div style="font-size:11px;color:var(--text-faint);margin-bottom:4px">URL — the repository address (github.com/you/repo.git or your Gitea), or a WebDAV file URL</div>
|
||||||
<input type="text" id="syncUrl" class="so-input"
|
<input type="text" id="syncUrl" class="so-input"
|
||||||
placeholder="https://git.example.com/api/v1/repos/OWNER/REPO/contents/vault-sync.json"
|
placeholder="https://github.com/you/repo.git"
|
||||||
style="width:100%;font-size:12px">
|
style="width:100%;font-size:12px">
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:8px;margin-bottom:6px">
|
<div style="display:flex;gap:8px;margin-bottom:6px">
|
||||||
@@ -873,9 +873,10 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p style="font-size:11px;color:var(--text-faint);margin:0 0 8px;line-height:1.5">
|
<p style="font-size:11px;color:var(--text-faint);margin:0 0 8px;line-height:1.5">
|
||||||
On Gitea the username is ignored and the token needs
|
On a Git repository the username is ignored and the
|
||||||
<b>write:repository</b> — a read-only one can pull but
|
token needs write access (Gitea: <b>write:repository</b>,
|
||||||
never push. Each sync is a commit, and Git history is
|
GitHub: <b>Contents: Read and write</b>) — a read-only
|
||||||
|
one can pull but never push. Each sync is a commit, and Git history is
|
||||||
kept: an old sync password stays attackable in earlier
|
kept: an old sync password stays attackable in earlier
|
||||||
versions, so prune the history if you rotate it.
|
versions, so prune the history if you rotate it.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
+10
-3
@@ -147,6 +147,8 @@ function autofillPushHotkeys(silent) {
|
|||||||
const BROWSER_SUFFIX_RE =
|
const BROWSER_SUFFIX_RE =
|
||||||
/\s*[-—–|]\s*(google chrome|chromium|mozilla firefox|firefox|microsoft edge|edge|brave|opera|vivaldi|safari|tor browser|tor|arc)\s*$/i;
|
/\s*[-—–|]\s*(google chrome|chromium|mozilla firefox|firefox|microsoft edge|edge|brave|opera|vivaldi|safari|tor browser|tor|arc)\s*$/i;
|
||||||
|
|
||||||
|
const AUTOFILL_SKIP_TAG = 'no-autofill';
|
||||||
|
|
||||||
function autofillStripBrowserSuffix(title) {
|
function autofillStripBrowserSuffix(title) {
|
||||||
return (title || '').replace(BROWSER_SUFFIX_RE, '').trim();
|
return (title || '').replace(BROWSER_SUFFIX_RE, '').trim();
|
||||||
}
|
}
|
||||||
@@ -274,9 +276,14 @@ async function autofillHandleRequest(windowTitle, kind) {
|
|||||||
// The 2FA hotkey can only serve entries that actually carry a TOTP secret
|
// The 2FA hotkey can only serve entries that actually carry a TOTP secret
|
||||||
// — filtering here (not after scoring) also means the picker never lists a
|
// — filtering here (not after scoring) also means the picker never lists a
|
||||||
// candidate that would fail once chosen.
|
// candidate that would fail once chosen.
|
||||||
const pool = (kind === 'totp')
|
// EXCLUE DE L'AUTOFILL par l'etiquette reservee AUTOFILL_SKIP_TAG : une
|
||||||
? state.entries.filter(e => e.totp_secret && e.totp_iv)
|
// etiquette et non un champ, car elle voyage deja partout (chiffree, synchro,
|
||||||
: state.entries;
|
// export, rotation) sans une colonne de plus, et un clic dessus liste toutes
|
||||||
|
// les entrees exclues. La recherche rapide (Ctrl+Shift+Q) les garde : c'est
|
||||||
|
// un choix explicite, pas une correspondance devinee sur un titre.
|
||||||
|
const pool = state.entries
|
||||||
|
.filter(e => !parseTags(e.tags).some(t => t.toLowerCase() === AUTOFILL_SKIP_TAG))
|
||||||
|
.filter(e => kind !== 'totp' || (e.totp_secret && e.totp_iv));
|
||||||
const scored = pool
|
const scored = pool
|
||||||
.map(e => ({ entry: e, score: autofillScore(e, titleLower) }))
|
.map(e => ({ entry: e, score: autofillScore(e, titleLower) }))
|
||||||
.filter(x => x.score > 0)
|
.filter(x => x.score > 0)
|
||||||
|
|||||||
@@ -3606,8 +3606,10 @@ function renderTableRow(e) {
|
|||||||
break;
|
break;
|
||||||
case 'actions': {
|
case 'actions': {
|
||||||
td = el('td', { class: 'col-actions' });
|
td = el('td', { class: 'col-actions' });
|
||||||
|
// TOUJOURS VISIBLE, contrairement aux autres actions de la
|
||||||
|
// ligne : c'est le geste le plus frequent de la vue tableau.
|
||||||
const pwBtn = el('button', {
|
const pwBtn = el('button', {
|
||||||
class: 'icon-btn icon-btn-sm',
|
class: 'icon-btn icon-btn-sm is-always',
|
||||||
title: e.kind === 'note' ? 'Copy note content' : 'Copy password',
|
title: e.kind === 'note' ? 'Copy note content' : 'Copy password',
|
||||||
on: { click: ev => { ev.stopPropagation(); copyPassword(e); } },
|
on: { click: ev => { ev.stopPropagation(); copyPassword(e); } },
|
||||||
});
|
});
|
||||||
@@ -6859,7 +6861,7 @@ const SETTINGS_TAB_OF = {
|
|||||||
'Fill bar': 'fillbar',
|
'Fill bar': 'fillbar',
|
||||||
'Account': 'account',
|
'Account': 'account',
|
||||||
'PIN unlock': 'account', 'Quick unlock': 'account', 'Recovery key': 'account',
|
'PIN unlock': 'account', 'Quick unlock': 'account', 'Recovery key': 'account',
|
||||||
'Export': 'sync', 'Sync (WebDAV or Gitea)': 'sync',
|
'Export': 'sync', 'Sync (WebDAV or Git repository)': 'sync',
|
||||||
'Auto-backup': 'sync', 'Import': 'sync',
|
'Auto-backup': 'sync', 'Import': 'sync',
|
||||||
};
|
};
|
||||||
let settingsActiveTab = 'general';
|
let settingsActiveTab = 'general';
|
||||||
@@ -8219,8 +8221,17 @@ async function init() {
|
|||||||
if (syncCfgBox) syncCfgBox.style.display = on ? '' : 'none';
|
if (syncCfgBox) syncCfgBox.style.display = on ? '' : 'none';
|
||||||
toast(on ? 'Sync enabled' : 'Sync disabled');
|
toast(on ? 'Sync enabled' : 'Sync disabled');
|
||||||
});
|
});
|
||||||
if (syncUrlEl) syncUrlEl.addEventListener('change', e =>
|
// L'adresse du depot suffit (github.com/O/R.git, Gitea) : elle est
|
||||||
Bridge.setPref(SYNC_PREFS.url, e.target.value.trim()));
|
// convertie en URL d'API, et le champ montre ce qui est enregistre.
|
||||||
|
if (syncUrlEl) syncUrlEl.addEventListener('change', e => {
|
||||||
|
const raw = e.target.value.trim();
|
||||||
|
const url = syncNormalizeRepoUrl(raw);
|
||||||
|
if (url !== raw) {
|
||||||
|
e.target.value = url;
|
||||||
|
toast('Repository address converted to its API URL');
|
||||||
|
}
|
||||||
|
Bridge.setPref(SYNC_PREFS.url, url);
|
||||||
|
});
|
||||||
if (syncUserEl) syncUserEl.addEventListener('change', e =>
|
if (syncUserEl) syncUserEl.addEventListener('change', e =>
|
||||||
Bridge.setPref(SYNC_PREFS.user, e.target.value.trim()));
|
Bridge.setPref(SYNC_PREFS.user, e.target.value.trim()));
|
||||||
if (syncPwdEl) syncPwdEl.addEventListener('change', e =>
|
if (syncPwdEl) syncPwdEl.addEventListener('change', e =>
|
||||||
|
|||||||
+30
-4
@@ -90,13 +90,36 @@ function syncIsGitea(url) {
|
|||||||
return u.includes('/api/v1/repos/') || u.includes('api.github.com/repos/');
|
return u.includes('/api/v1/repos/') || u.includes('api.github.com/repos/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ADRESSE DU DEPOT TELLE QUE LE NAVIGATEUR (OU "git clone") LA DONNE.
|
||||||
|
// https://github.com/O/R(.git) -> API de GitHub ; https://hote/O/R(.git) -> API
|
||||||
|
// de Gitea. Fichier par defaut vault-sync.json, ou celui d'un lien .../blob/B/F
|
||||||
|
// (GitHub) ou .../src/branch/B/F (Gitea). Tout le reste passe tel quel - une
|
||||||
|
// URL WebDAV, une URL d'API deja complete. GARDE : un dernier segment qui a
|
||||||
|
// l'air d'un fichier (.json...) n'est pas un depot - sinon
|
||||||
|
// https://serveur/webdav/vault.json deviendrait une API Gitea imaginaire.
|
||||||
|
function syncNormalizeRepoUrl(u) {
|
||||||
|
u = (u || '').replace(/\s+/g, '');
|
||||||
|
if (!u || syncIsGitea(u)) return u;
|
||||||
|
const g = u.match(/^https?:\/\/(www\.)?github\.com\/([^\/]+)\/([^\/]+?)(?:\.git)?(?:\/blob\/[^\/]+\/(.+?))?\/?$/i);
|
||||||
|
if (g) return 'https://api.github.com/repos/' + g[2] + '/' + g[3] +
|
||||||
|
'/contents/' + (g[4] || 'vault-sync.json');
|
||||||
|
const m = u.match(/^(https?:\/\/[^\/]+)\/([^\/]+)\/([^\/]+?)(\.git)?(?:\/src\/branch\/[^\/]+\/(.+?))?\/?$/i);
|
||||||
|
if (!m) return u;
|
||||||
|
if (!m[4] && !m[5] && /\.[a-z0-9]{2,5}$/i.test(m[3])) return u; // un fichier, pas un depot
|
||||||
|
return m[1] + '/api/v1/repos/' + m[2] + '/' + m[3] + '/contents/' + (m[5] || 'vault-sync.json');
|
||||||
|
}
|
||||||
|
|
||||||
// UN DEPOT PUBLIC EST REFUSE. Chaque synchro est un commit : l'historique garde
|
// UN DEPOT PUBLIC EST REFUSE. Chaque synchro est un commit : l'historique garde
|
||||||
// tous les instantanes, chacun attaquable hors ligne par qui peut les lire.
|
// tous les instantanes, chacun attaquable hors ligne par qui peut les lire.
|
||||||
// '' = prive ou impossible a savoir (on ne bloque pas sur un doute reseau).
|
// '' = prive ou impossible a savoir (on ne bloque pas sur un doute reseau).
|
||||||
async function syncRepoPublicWarning(cfg) {
|
async function syncRepoPublicWarning(cfg) {
|
||||||
if (!syncIsGitea(cfg.url)) return '';
|
if (!syncIsGitea(cfg.url)) return '';
|
||||||
const repoUrl = cfg.url.split('/contents/')[0];
|
const repoUrl = cfg.url.split('/contents/')[0];
|
||||||
const r = await _webdavCall('get', repoUrl, cfg.user, cfg.pwd);
|
// SANS JETON, expres : "public" veut dire "visible par n'importe qui", et
|
||||||
|
// c'est exactement la question posee. Avec un jeton faux, GitHub repondait
|
||||||
|
// 401 avant de dire quoi que ce soit du depot ; un depot prive repond 404
|
||||||
|
// sans jeton, ce qui veut dire "pas public".
|
||||||
|
const r = await _webdavCall('get', repoUrl, '', '');
|
||||||
if (r.status < 200 || r.status >= 300 || !r.payload) return '';
|
if (r.status < 200 || r.status >= 300 || !r.payload) return '';
|
||||||
try {
|
try {
|
||||||
const o = JSON.parse(new TextDecoder().decode(base64ToBytes(r.payload)));
|
const o = JSON.parse(new TextDecoder().decode(base64ToBytes(r.payload)));
|
||||||
@@ -273,7 +296,9 @@ async function loadSyncConfig(withSecrets) {
|
|||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
enabled: enabled === '1',
|
enabled: enabled === '1',
|
||||||
url: url || '',
|
// Convertie aussi a la LECTURE : une adresse de depot enregistree par un
|
||||||
|
// exe anterieur (ou tapee avant la conversion) ne l'avait jamais ete.
|
||||||
|
url: syncNormalizeRepoUrl(url || ''),
|
||||||
user: user || '',
|
user: user || '',
|
||||||
pwd: pwd || '',
|
pwd: pwd || '',
|
||||||
encPwd: encPwd || '',
|
encPwd: encPwd || '',
|
||||||
@@ -288,6 +313,9 @@ async function syncTestConnection() {
|
|||||||
const cfg = await loadSyncConfig(true); // needs the WebDAV credentials
|
const cfg = await loadSyncConfig(true); // needs the WebDAV credentials
|
||||||
if (!cfg || !cfg.url) return toast('Set the WebDAV URL first', 'warning');
|
if (!cfg || !cfg.url) return toast('Set the WebDAV URL first', 'warning');
|
||||||
toast('Testing connection…');
|
toast('Testing connection…');
|
||||||
|
// LE DEPOT PUBLIC D'ABORD : il est refuse quel que soit le jeton.
|
||||||
|
const pubFirst = await syncRepoPublicWarning(cfg);
|
||||||
|
if (pubFirst) return toast(pubFirst, 'error');
|
||||||
const r = await _webdavCall('test', cfg.url, cfg.user, cfg.pwd);
|
const r = await _webdavCall('test', cfg.url, cfg.user, cfg.pwd);
|
||||||
// A reachable Gitea with a valid token is not enough: a URL pointing one
|
// A reachable Gitea with a valid token is not enough: a URL pointing one
|
||||||
// path segment off answers 200 with JSON that is not a file. Checking the
|
// path segment off answers 200 with JSON that is not a file. Checking the
|
||||||
@@ -297,8 +325,6 @@ async function syncTestConnection() {
|
|||||||
try {
|
try {
|
||||||
un = giteaUnwrap(new TextDecoder().decode(base64ToBytes(r.payload || '')));
|
un = giteaUnwrap(new TextDecoder().decode(base64ToBytes(r.payload || '')));
|
||||||
} catch (e) { /* traite comme une forme inattendue */ }
|
} catch (e) { /* traite comme une forme inattendue */ }
|
||||||
const pub = await syncRepoPublicWarning(cfg);
|
|
||||||
if (pub) return toast(pub, 'error');
|
|
||||||
return un
|
return un
|
||||||
? toast('Connection OK · file found in the repository')
|
? toast('Connection OK · file found in the repository')
|
||||||
: toast('Reached Gitea, but this URL does not return a file — ' +
|
: toast('Reached Gitea, but this URL does not return a file — ' +
|
||||||
|
|||||||
Reference in New Issue
Block a user