2023-07-27 17:05:56 +02:00
|
|
|
import Logger from '@joplin/utils/Logger';
|
2021-01-07 18:30:53 +02:00
|
|
|
import shim from '../../shim';
|
|
|
|
import { PluginManifest } from './utils/types';
|
|
|
|
const md5 = require('md5');
|
2023-09-27 01:01:52 +02:00
|
|
|
import { compareVersions } from 'compare-versions';
|
2021-01-07 18:30:53 +02:00
|
|
|
|
2021-06-01 11:09:46 +02:00
|
|
|
const logger = Logger.create('RepositoryApi');
|
|
|
|
|
|
|
|
interface ReleaseAsset {
|
|
|
|
name: string;
|
|
|
|
browser_download_url: string;
|
|
|
|
}
|
|
|
|
|
|
|
|
interface Release {
|
|
|
|
upload_url: string;
|
|
|
|
assets: ReleaseAsset[];
|
|
|
|
}
|
|
|
|
|
2021-09-21 17:22:19 +02:00
|
|
|
const findWorkingGitHubUrl = async (defaultContentUrl: string): Promise<string> => {
|
|
|
|
// From: https://github.com/laurent22/joplin/issues/5161#issuecomment-921642721
|
|
|
|
|
|
|
|
const mirrorUrls = [
|
|
|
|
defaultContentUrl,
|
|
|
|
'https://cdn.staticaly.com/gh/joplin/plugins/master',
|
|
|
|
'https://ghproxy.com/https://raw.githubusercontent.com/joplin/plugins/master',
|
|
|
|
'https://cdn.jsdelivr.net/gh/joplin/plugins@master',
|
|
|
|
'https://raw.fastgit.org/joplin/plugins/master',
|
|
|
|
];
|
|
|
|
|
|
|
|
for (const mirrorUrl of mirrorUrls) {
|
|
|
|
try {
|
|
|
|
// We try to fetch .gitignore, which is smaller than the whole manifest
|
2022-10-15 23:21:06 +02:00
|
|
|
await shim.fetch(`${mirrorUrl}/.gitignore`);
|
2021-09-21 17:22:19 +02:00
|
|
|
} catch (error) {
|
|
|
|
logger.info(`findWorkingMirror: Could not connect to ${mirrorUrl}:`, error);
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
logger.info(`findWorkingMirror: Using: ${mirrorUrl}`);
|
|
|
|
|
|
|
|
return mirrorUrl;
|
|
|
|
}
|
|
|
|
|
|
|
|
logger.info('findWorkingMirror: Could not find any working GitHub URL');
|
|
|
|
|
|
|
|
return defaultContentUrl;
|
|
|
|
};
|
|
|
|
|
2021-01-07 18:30:53 +02:00
|
|
|
export default class RepositoryApi {
|
|
|
|
|
2021-01-20 00:58:09 +02:00
|
|
|
// As a base URL, this class can support either a remote repository or a
|
|
|
|
// local one (a directory path), which is useful for testing.
|
|
|
|
//
|
|
|
|
// For now, if the baseUrl is an actual URL it's assumed it's a GitHub repo
|
|
|
|
// URL, such as https://github.com/joplin/plugins
|
2021-01-07 18:30:53 +02:00
|
|
|
//
|
|
|
|
// Later on, other repo types could be supported.
|
|
|
|
private baseUrl_: string;
|
|
|
|
private tempDir_: string;
|
2021-06-01 11:09:46 +02:00
|
|
|
private release_: Release = null;
|
2021-01-07 18:30:53 +02:00
|
|
|
private manifests_: PluginManifest[] = null;
|
2021-09-21 17:22:19 +02:00
|
|
|
private githubApiUrl_: string;
|
|
|
|
private contentBaseUrl_: string;
|
2023-06-30 10:07:03 +02:00
|
|
|
private isUsingDefaultContentUrl_ = true;
|
2021-01-07 18:30:53 +02:00
|
|
|
|
|
|
|
public constructor(baseUrl: string, tempDir: string) {
|
|
|
|
this.baseUrl_ = baseUrl;
|
|
|
|
this.tempDir_ = tempDir;
|
|
|
|
}
|
|
|
|
|
2021-06-01 11:09:46 +02:00
|
|
|
public async initialize() {
|
2021-09-21 17:22:19 +02:00
|
|
|
// https://github.com/joplin/plugins
|
|
|
|
// https://api.github.com/repos/joplin/plugins/releases
|
|
|
|
this.githubApiUrl_ = this.baseUrl_.replace(/^(https:\/\/)(github\.com\/)(.*)$/, '$1api.$2repos/$3');
|
2023-10-21 18:00:01 +02:00
|
|
|
const defaultContentBaseUrl = this.isLocalRepo ? this.baseUrl_ : `${this.baseUrl_.replace(/github\.com/, 'raw.githubusercontent.com')}/master`;
|
|
|
|
this.contentBaseUrl_ = this.isLocalRepo ? defaultContentBaseUrl : await findWorkingGitHubUrl(defaultContentBaseUrl);
|
2021-09-21 17:22:19 +02:00
|
|
|
|
|
|
|
this.isUsingDefaultContentUrl_ = this.contentBaseUrl_ === defaultContentBaseUrl;
|
|
|
|
|
2021-06-01 11:09:46 +02:00
|
|
|
await this.loadManifests();
|
|
|
|
await this.loadRelease();
|
|
|
|
}
|
|
|
|
|
|
|
|
private async loadManifests() {
|
2021-01-20 00:58:09 +02:00
|
|
|
const manifestsText = await this.fetchText('manifests.json');
|
|
|
|
try {
|
|
|
|
const manifests = JSON.parse(manifestsText);
|
|
|
|
if (!manifests) throw new Error('Invalid or missing JSON');
|
2021-09-21 17:22:19 +02:00
|
|
|
|
2021-01-20 00:58:09 +02:00
|
|
|
this.manifests_ = Object.keys(manifests).map(id => {
|
2021-09-21 17:22:19 +02:00
|
|
|
const m: PluginManifest = manifests[id];
|
|
|
|
// If we don't control the repository, we can't recommend
|
|
|
|
// anything on it since it could have been modified.
|
|
|
|
if (!this.isUsingDefaultContentUrl) m._recommended = false;
|
|
|
|
return m;
|
2021-01-20 00:58:09 +02:00
|
|
|
});
|
|
|
|
} catch (error) {
|
|
|
|
throw new Error(`Could not parse JSON: ${error.message}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-09-21 17:22:19 +02:00
|
|
|
public get isUsingDefaultContentUrl() {
|
|
|
|
return this.isUsingDefaultContentUrl_;
|
|
|
|
}
|
|
|
|
|
2021-06-01 11:09:46 +02:00
|
|
|
private get githubApiUrl(): string {
|
2021-09-21 17:22:19 +02:00
|
|
|
return this.githubApiUrl_;
|
|
|
|
}
|
|
|
|
|
|
|
|
public get contentBaseUrl(): string {
|
|
|
|
if (this.isLocalRepo) {
|
|
|
|
return this.baseUrl_;
|
|
|
|
} else {
|
|
|
|
return this.contentBaseUrl_;
|
|
|
|
}
|
2021-06-01 11:09:46 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
private async loadRelease() {
|
|
|
|
this.release_ = null;
|
|
|
|
|
|
|
|
if (this.isLocalRepo) return;
|
|
|
|
|
|
|
|
try {
|
2022-10-15 23:21:06 +02:00
|
|
|
const response = await shim.fetch(`${this.githubApiUrl}/releases`);
|
2021-06-01 11:09:46 +02:00
|
|
|
const releases = await response.json();
|
|
|
|
if (!releases.length) throw new Error('No release was found');
|
|
|
|
this.release_ = releases[0];
|
|
|
|
} catch (error) {
|
|
|
|
logger.warn('Could not load release - files will be downloaded from the repository directly:', error);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-01-20 00:58:09 +02:00
|
|
|
private get isLocalRepo(): boolean {
|
|
|
|
return this.baseUrl_.indexOf('http') !== 0;
|
|
|
|
}
|
|
|
|
|
2021-06-01 11:09:46 +02:00
|
|
|
private assetFileUrl(pluginId: string): string {
|
|
|
|
if (this.release_) {
|
|
|
|
const asset = this.release_.assets.find(asset => {
|
|
|
|
const s = asset.name.split('@');
|
|
|
|
s.pop();
|
|
|
|
const id = s.join('@');
|
|
|
|
return id === pluginId;
|
|
|
|
});
|
|
|
|
|
|
|
|
if (asset) return asset.browser_download_url;
|
|
|
|
|
|
|
|
logger.warn(`Could not get plugin from release: ${pluginId}`);
|
|
|
|
}
|
|
|
|
|
|
|
|
// If we couldn't get the plugin file from the release, get it directly
|
|
|
|
// from the repository instead.
|
|
|
|
return this.repoFileUrl(`plugins/${pluginId}/plugin.jpl`);
|
|
|
|
}
|
|
|
|
|
|
|
|
private repoFileUrl(relativePath: string): string {
|
2021-01-07 18:30:53 +02:00
|
|
|
return `${this.contentBaseUrl}/${relativePath}`;
|
|
|
|
}
|
|
|
|
|
|
|
|
private async fetchText(path: string): Promise<string> {
|
2021-01-20 00:58:09 +02:00
|
|
|
if (this.isLocalRepo) {
|
2021-06-01 11:09:46 +02:00
|
|
|
return shim.fsDriver().readFile(this.repoFileUrl(path), 'utf8');
|
2021-01-20 00:58:09 +02:00
|
|
|
} else {
|
2021-06-01 11:09:46 +02:00
|
|
|
return shim.fetchText(this.repoFileUrl(path));
|
2021-01-20 00:58:09 +02:00
|
|
|
}
|
2021-01-07 18:30:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
public async search(query: string): Promise<PluginManifest[]> {
|
|
|
|
query = query.toLowerCase().trim();
|
|
|
|
|
|
|
|
const manifests = await this.manifests();
|
|
|
|
const output: PluginManifest[] = [];
|
|
|
|
|
|
|
|
for (const manifest of manifests) {
|
|
|
|
for (const field of ['name', 'description']) {
|
|
|
|
const v = (manifest as any)[field];
|
|
|
|
if (!v) continue;
|
|
|
|
|
|
|
|
if (v.toLowerCase().indexOf(query) >= 0) {
|
|
|
|
output.push(manifest);
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return output;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Returns a temporary path, where the plugin has been downloaded to. Temp
|
|
|
|
// file should be deleted by caller.
|
|
|
|
public async downloadPlugin(pluginId: string): Promise<string> {
|
|
|
|
const manifests = await this.manifests();
|
|
|
|
const manifest = manifests.find(m => m.id === pluginId);
|
|
|
|
if (!manifest) throw new Error(`No manifest for plugin ID "${pluginId}"`);
|
|
|
|
|
2021-06-01 11:09:46 +02:00
|
|
|
const fileUrl = this.assetFileUrl(manifest.id); // this.repoFileUrl(`plugins/${manifest.id}/plugin.jpl`);
|
2021-01-07 18:30:53 +02:00
|
|
|
const hash = md5(Date.now() + Math.random());
|
|
|
|
const targetPath = `${this.tempDir_}/${hash}_${manifest.id}.jpl`;
|
|
|
|
|
2021-01-20 00:58:09 +02:00
|
|
|
if (this.isLocalRepo) {
|
|
|
|
await shim.fsDriver().copy(fileUrl, targetPath);
|
|
|
|
} else {
|
|
|
|
const response = await shim.fetchBlob(fileUrl, {
|
|
|
|
path: targetPath,
|
|
|
|
});
|
|
|
|
|
|
|
|
if (!response.ok) throw new Error(`Could not download plugin "${pluginId}" from "${fileUrl}"`);
|
|
|
|
}
|
|
|
|
|
2021-01-07 18:30:53 +02:00
|
|
|
return targetPath;
|
|
|
|
}
|
|
|
|
|
2021-01-20 00:58:09 +02:00
|
|
|
public async manifests(): Promise<PluginManifest[]> {
|
|
|
|
if (!this.manifests_) throw new Error('Manifests have no been loaded!');
|
|
|
|
return this.manifests_;
|
|
|
|
}
|
|
|
|
|
2023-03-17 10:50:51 +02:00
|
|
|
public async canBeUpdatedPlugins(installedManifests: PluginManifest[], appVersion: string): Promise<string[]> {
|
2021-01-20 00:58:09 +02:00
|
|
|
const output = [];
|
|
|
|
|
|
|
|
for (const manifest of installedManifests) {
|
2023-03-17 10:50:51 +02:00
|
|
|
const canBe = await this.pluginCanBeUpdated(manifest.id, manifest.version, appVersion);
|
2021-01-20 00:58:09 +02:00
|
|
|
if (canBe) output.push(manifest.id);
|
2021-01-07 18:30:53 +02:00
|
|
|
}
|
2021-01-20 00:58:09 +02:00
|
|
|
|
|
|
|
return output;
|
|
|
|
}
|
|
|
|
|
2023-03-17 10:50:51 +02:00
|
|
|
public async pluginCanBeUpdated(pluginId: string, installedVersion: string, appVersion: string): Promise<boolean> {
|
2021-01-20 00:58:09 +02:00
|
|
|
const manifest = (await this.manifests()).find(m => m.id === pluginId);
|
|
|
|
if (!manifest) return false;
|
2023-03-17 10:50:51 +02:00
|
|
|
return compareVersions(installedVersion, manifest.version) < 0 && compareVersions(appVersion, manifest.app_min_version) >= 0;
|
2021-01-07 18:30:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
}
|