You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-08-27 20:29:45 +02:00
Compare commits
14 Commits
v2.0.6
...
server-v2.
Author | SHA1 | Date | |
---|---|---|---|
|
3c897b2679 | ||
|
1ef9d1bf78 | ||
|
c5c38a323f | ||
|
01e6ca4616 | ||
|
24a586c537 | ||
|
5d233a7387 | ||
|
054e5428d5 | ||
|
0120df7bdb | ||
|
a36b13dcb4 | ||
|
b81c300907 | ||
|
1ded589eeb | ||
|
315216132f | ||
|
2eaa821272 | ||
|
7c93e268e4 |
@@ -278,6 +278,9 @@ packages/app-desktop/gui/MainScreen/commands/showNoteContentProperties.js.map
|
||||
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.d.ts
|
||||
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.js
|
||||
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.js.map
|
||||
packages/app-desktop/gui/MainScreen/commands/showPrompt.d.ts
|
||||
packages/app-desktop/gui/MainScreen/commands/showPrompt.js
|
||||
packages/app-desktop/gui/MainScreen/commands/showPrompt.js.map
|
||||
packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.d.ts
|
||||
packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.js
|
||||
packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.js.map
|
||||
@@ -1601,6 +1604,9 @@ packages/renderer/pathUtils.js.map
|
||||
packages/renderer/utils.d.ts
|
||||
packages/renderer/utils.js
|
||||
packages/renderer/utils.js.map
|
||||
packages/tools/buildServerDocker.d.ts
|
||||
packages/tools/buildServerDocker.js
|
||||
packages/tools/buildServerDocker.js.map
|
||||
packages/tools/generate-database-types.d.ts
|
||||
packages/tools/generate-database-types.js
|
||||
packages/tools/generate-database-types.js.map
|
||||
|
4
.github/scripts/run_ci.sh
vendored
4
.github/scripts/run_ci.sh
vendored
@@ -38,6 +38,7 @@ echo "GITHUB_REF=$GITHUB_REF"
|
||||
echo "RUNNER_OS=$RUNNER_OS"
|
||||
echo "GIT_TAG_NAME=$GIT_TAG_NAME"
|
||||
|
||||
echo "IS_CONTINUOUS_INTEGRATION=$IS_CONTINUOUS_INTEGRATION"
|
||||
echo "IS_PULL_REQUEST=$IS_PULL_REQUEST"
|
||||
echo "IS_DEV_BRANCH=$IS_DEV_BRANCH"
|
||||
echo "IS_LINUX=$IS_LINUX"
|
||||
@@ -122,6 +123,9 @@ cd "$ROOT_DIR/packages/app-desktop"
|
||||
|
||||
if [[ $GIT_TAG_NAME = v* ]]; then
|
||||
USE_HARD_LINKS=false npm run dist
|
||||
elif [[ $GIT_TAG_NAME = server-v* ]]; then
|
||||
cd "$ROOT_DIR"
|
||||
npm run buildServerDocker -- $GIT_TAG_NAME
|
||||
else
|
||||
USE_HARD_LINKS=false npm run dist -- --publish=never
|
||||
fi
|
||||
|
20
.github/workflows/github-actions-main.yml
vendored
20
.github/workflows/github-actions-main.yml
vendored
@@ -1,5 +1,5 @@
|
||||
name: Joplin Continuous Integration
|
||||
on: [push]
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
Main:
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -12,6 +12,7 @@ jobs:
|
||||
# exist) since otherwise it will make the whole build fails, even though
|
||||
# it might work without update. libsecret-1-dev is required for keytar -
|
||||
# https://github.com/atom/node-keytar
|
||||
|
||||
- name: Install Linux dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
@@ -19,11 +20,27 @@ jobs:
|
||||
sudo apt-get install -y gettext
|
||||
sudo apt-get install -y libsecret-1-dev
|
||||
|
||||
- name: Install Docker Engine
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get install -y apt-transport-https
|
||||
sudo apt-get install -y ca-certificates
|
||||
sudo apt-get install -y curl
|
||||
sudo apt-get install -y gnupg
|
||||
sudo apt-get install -y lsb-release
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
|
||||
sudo apt-get install -y docker-ce docker-ce-cli containerd.io
|
||||
|
||||
- uses: actions/checkout@v2
|
||||
- uses: olegtarasov/get-tag@v2.1
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '12'
|
||||
|
||||
- uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Run script...
|
||||
env:
|
||||
@@ -33,5 +50,6 @@ jobs:
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
IS_CONTINUOUS_INTEGRATION: 1
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE}/.github/scripts/run_ci.sh"
|
||||
|
6
.gitignore
vendored
6
.gitignore
vendored
@@ -264,6 +264,9 @@ packages/app-desktop/gui/MainScreen/commands/showNoteContentProperties.js.map
|
||||
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.d.ts
|
||||
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.js
|
||||
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.js.map
|
||||
packages/app-desktop/gui/MainScreen/commands/showPrompt.d.ts
|
||||
packages/app-desktop/gui/MainScreen/commands/showPrompt.js
|
||||
packages/app-desktop/gui/MainScreen/commands/showPrompt.js.map
|
||||
packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.d.ts
|
||||
packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.js
|
||||
packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.js.map
|
||||
@@ -1587,6 +1590,9 @@ packages/renderer/pathUtils.js.map
|
||||
packages/renderer/utils.d.ts
|
||||
packages/renderer/utils.js
|
||||
packages/renderer/utils.js.map
|
||||
packages/tools/buildServerDocker.d.ts
|
||||
packages/tools/buildServerDocker.js
|
||||
packages/tools/buildServerDocker.js.map
|
||||
packages/tools/generate-database-types.d.ts
|
||||
packages/tools/generate-database-types.js
|
||||
packages/tools/generate-database-types.js.map
|
||||
|
2
BUILD.md
2
BUILD.md
@@ -1,5 +1,3 @@
|
||||
[](https://travis-ci.com/laurent22/joplin) [](https://ci.appveyor.com/project/laurent22/joplin)
|
||||
|
||||
# Building the applications
|
||||
|
||||
The Joplin source code is hosted on a [monorepo](https://en.wikipedia.org/wiki/Monorepo) managed by Lerna. The usage of Lerna is mostly transparent as the needed commands have been moved to the root package.json and thus are invoked for example when running `npm install` or `npm run watch`. The main thing to know about Lerna is that it links the packages in the monorepo using `npm link`, so if you check the node_modules directory you will see links instead of actual directories for certain packages. This is something to keep in mind as these links can cause issues in some cases.
|
||||
|
@@ -36,6 +36,7 @@
|
||||
"releaseIOS": "node packages/tools/release-ios.js",
|
||||
"releasePluginGenerator": "node packages/tools/release-plugin-generator.js",
|
||||
"releaseServer": "node packages/tools/release-server.js",
|
||||
"buildServerDocker": "node packages/tools/buildServerDocker.js",
|
||||
"setupNewRelease": "node ./packages/tools/setupNewRelease",
|
||||
"test-ci": "lerna run test-ci --stream",
|
||||
"test": "lerna run test --stream",
|
||||
|
@@ -74,6 +74,7 @@ const commands = [
|
||||
require('./gui/MainScreen/commands/toggleNoteList'),
|
||||
require('./gui/MainScreen/commands/toggleSideBar'),
|
||||
require('./gui/MainScreen/commands/toggleVisiblePanes'),
|
||||
require('./gui/MainScreen/commands/showPrompt'),
|
||||
require('./gui/NoteEditor/commands/focusElementNoteBody'),
|
||||
require('./gui/NoteEditor/commands/focusElementNoteTitle'),
|
||||
require('./gui/NoteEditor/commands/showLocalSearch'),
|
||||
|
@@ -135,6 +135,7 @@ const commands = [
|
||||
require('./commands/openNote'),
|
||||
require('./commands/openFolder'),
|
||||
require('./commands/openTag'),
|
||||
require('./commands/showPrompt'),
|
||||
];
|
||||
|
||||
class MainScreenComponent extends React.Component<Props, State> {
|
||||
|
41
packages/app-desktop/gui/MainScreen/commands/showPrompt.ts
Normal file
41
packages/app-desktop/gui/MainScreen/commands/showPrompt.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'showPrompt',
|
||||
};
|
||||
|
||||
enum PromptInputType {
|
||||
Dropdown = 'dropdown',
|
||||
Datetime = 'datetime',
|
||||
Tags = 'tags',
|
||||
Text = 'text',
|
||||
}
|
||||
|
||||
interface PromptConfig {
|
||||
label: string;
|
||||
inputType?: PromptInputType;
|
||||
value?: any;
|
||||
autocomplete?: any[];
|
||||
buttons?: string[];
|
||||
}
|
||||
|
||||
export const runtime = (comp: any): CommandRuntime => {
|
||||
return {
|
||||
execute: async (_context: CommandContext, config: PromptConfig) => {
|
||||
return new Promise((resolve) => {
|
||||
comp.setState({
|
||||
promptOptions: {
|
||||
...config,
|
||||
onClose: async (answer: any, buttonType: string) => {
|
||||
comp.setState({ promptOptions: null });
|
||||
resolve({
|
||||
answer: answer,
|
||||
buttonType: buttonType,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
@@ -95,7 +95,7 @@ export function ShareNoteDialog(props: Props) {
|
||||
|
||||
const copyLinksToClipboard = (shares: StateShare[]) => {
|
||||
const links = [];
|
||||
for (const share of shares) links.push(ShareService.instance().shareUrl(share));
|
||||
for (const share of shares) links.push(ShareService.instance().shareUrl(ShareService.instance().userId, share));
|
||||
clipboard.writeText(links.join('\n'));
|
||||
};
|
||||
|
||||
|
2
packages/app-desktop/package-lock.json
generated
2
packages/app-desktop/package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.js",
|
||||
"private": true,
|
||||
|
@@ -25,13 +25,13 @@ if [ "$RESET_ALL" == "1" ]; then
|
||||
rm -rf "$PROFILE_DIR"
|
||||
|
||||
echo "config keychain.supported 0" >> "$CMD_FILE"
|
||||
echo "config sync.target 9" >> "$CMD_FILE"
|
||||
echo "config sync.9.path http://api-joplincloud.local:22300" >> "$CMD_FILE"
|
||||
echo "config sync.9.username $USER_EMAIL" >> "$CMD_FILE"
|
||||
echo "config sync.9.password 123456" >> "$CMD_FILE"
|
||||
echo "config sync.target 10" >> "$CMD_FILE"
|
||||
echo "config sync.10.path http://api.joplincloud.local:22300" >> "$CMD_FILE"
|
||||
echo "config sync.10.username $USER_EMAIL" >> "$CMD_FILE"
|
||||
echo "config sync.10.password 123456" >> "$CMD_FILE"
|
||||
|
||||
if [ "$USER_NUM" == "1" ]; then
|
||||
curl --data '{"action": "createTestUsers"}' -H 'Content-Type: application/json' http://api-joplincloud.local:22300/api/debug
|
||||
curl --data '{"action": "createTestUsers"}' -H 'Content-Type: application/json' http://api.joplincloud.local:22300/api/debug
|
||||
|
||||
echo 'mkbook "shared"' >> "$CMD_FILE"
|
||||
echo 'mkbook "other"' >> "$CMD_FILE"
|
||||
|
@@ -27,7 +27,7 @@ module.exports = async function() {
|
||||
// Use stdio: 'pipe' so that execSync doesn't print error directly to stdout
|
||||
branch = execSync('git rev-parse --abbrev-ref HEAD', { stdio: 'pipe' }).toString().trim();
|
||||
hash = execSync('git log --pretty="%h" -1', { stdio: 'pipe' }).toString().trim();
|
||||
// The builds in CI are done from a 'detached HEAD' state, thus the branch name will be 'HEAD' for Travis builds.
|
||||
// The builds in CI are done from a 'detached HEAD' state, thus the branch name will be 'HEAD' for CI builds.
|
||||
} catch (err) {
|
||||
// Don't display error object as it's a "fatal" error, but
|
||||
// not for us, since is it not critical information
|
||||
|
@@ -25,8 +25,8 @@ module.exports = async function(params) {
|
||||
|
||||
console.info('Checking if notarization should be done...');
|
||||
|
||||
if (!process.env.TRAVIS || !process.env.TRAVIS_TAG) {
|
||||
console.info(`Either not running in CI or not processing a tag - skipping notarization. process.env.TRAVIS = ${process.env.TRAVIS}; process.env.TRAVIS_TAG = ${process.env.TRAVIS}`);
|
||||
if (!process.env.IS_CONTINUOUS_INTEGRATION || !process.env.GIT_TAG_NAME) {
|
||||
console.info(`Either not running in CI or not processing a tag - skipping notarization. process.env.IS_CONTINUOUS_INTEGRATION = ${process.env.IS_CONTINUOUS_INTEGRATION}; process.env.GIT_TAG_NAME = ${process.env.GIT_TAG_NAME}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -45,9 +45,8 @@ module.exports = async function(params) {
|
||||
|
||||
console.log(`Notarizing ${appId} found at ${appPath}`);
|
||||
|
||||
// Every x seconds we print something to stdout, otherwise Travis will
|
||||
// timeout the task after 10 minutes, and Apple notarization can take more
|
||||
// time.
|
||||
// Every x seconds we print something to stdout, otherwise CI may timeout
|
||||
// the task after 10 minutes, and Apple notarization can take more time.
|
||||
const waitingIntervalId = setInterval(() => {
|
||||
console.log('.');
|
||||
}, 60000);
|
||||
|
@@ -768,7 +768,7 @@ export default class BaseApplication {
|
||||
if (Setting.value('env') === Env.Dev) {
|
||||
Setting.setValue('sync.10.path', 'https://api.joplincloud.com');
|
||||
Setting.setValue('sync.10.userContentPath', 'https://joplinusercontent.com');
|
||||
// Setting.setValue('sync.10.path', 'http://api-joplincloud.local:22300');
|
||||
// Setting.setValue('sync.10.path', 'http://api.joplincloud.local:22300');
|
||||
// Setting.setValue('sync.10.userContentPath', 'http://joplinusercontent.local:22300');
|
||||
}
|
||||
|
||||
|
@@ -56,8 +56,14 @@ export default class JoplinServerApi {
|
||||
return rtrimSlashes(this.options_.baseUrl());
|
||||
}
|
||||
|
||||
public userContentBaseUrl() {
|
||||
return this.options_.userContentBaseUrl() || this.baseUrl();
|
||||
public userContentBaseUrl(userId: string) {
|
||||
if (this.options_.userContentBaseUrl()) {
|
||||
if (!userId) throw new Error('User ID must be specified');
|
||||
const url = new URL(this.options_.userContentBaseUrl());
|
||||
return `${url.protocol}//${userId.substr(0, 10).toLowerCase()}.${url.host}`;
|
||||
} else {
|
||||
return this.baseUrl();
|
||||
}
|
||||
}
|
||||
|
||||
private async session() {
|
||||
|
@@ -83,4 +83,25 @@ describe('services_SearchEngineUtils', function() {
|
||||
expect(rows.map(r=>r.id)).toContain(todo2.id);
|
||||
}));
|
||||
});
|
||||
|
||||
it('remove auto added fields', (async () => {
|
||||
await Note.save({ title: 'abcd', body: 'body 1' });
|
||||
await searchEngine.syncTables();
|
||||
|
||||
const testCases = [
|
||||
['title', 'todo_due'],
|
||||
['title', 'todo_completed'],
|
||||
['title'],
|
||||
['title', 'todo_completed', 'todo_due'],
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const rows = await SearchEngineUtils.notesForQuery('abcd', false, { fields: [...testCase] }, searchEngine);
|
||||
testCase.push('type_');
|
||||
expect(Object.keys(rows[0]).length).toBe(testCase.length);
|
||||
for (const field of testCase) {
|
||||
expect(rows[0]).toHaveProperty(field);
|
||||
}
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
@@ -38,10 +38,10 @@ export default class SearchEngineUtils {
|
||||
isTodoAutoAdded = true;
|
||||
}
|
||||
|
||||
let isTodoCompletedAutoAdded = false;
|
||||
let todoCompletedAutoAdded = false;
|
||||
if (fields.indexOf('todo_completed') < 0) {
|
||||
fields.push('todo_completed');
|
||||
isTodoCompletedAutoAdded = true;
|
||||
todoCompletedAutoAdded = true;
|
||||
}
|
||||
|
||||
const previewOptions = Object.assign({}, {
|
||||
@@ -66,8 +66,8 @@ export default class SearchEngineUtils {
|
||||
const idx = noteIds.indexOf(filteredNotes[i].id);
|
||||
sortedNotes[idx] = filteredNotes[i];
|
||||
if (idWasAutoAdded) delete sortedNotes[idx].id;
|
||||
if (isTodoCompletedAutoAdded) delete sortedNotes[idx].is_todo;
|
||||
if (isTodoAutoAdded) delete sortedNotes[idx].todo_completed;
|
||||
if (todoCompletedAutoAdded) delete sortedNotes[idx].todo_completed;
|
||||
if (isTodoAutoAdded) delete sortedNotes[idx].is_todo;
|
||||
}
|
||||
|
||||
// Note that when the search engine index is somehow corrupted, it might contain
|
||||
|
@@ -33,6 +33,10 @@ export default class ShareService {
|
||||
return this.store.getState()[stateRootKey] as State;
|
||||
}
|
||||
|
||||
public get userId(): string {
|
||||
return this.api() ? this.api().userId : '';
|
||||
}
|
||||
|
||||
private api(): JoplinServerApi {
|
||||
if (this.api_) return this.api_;
|
||||
|
||||
@@ -136,8 +140,8 @@ export default class ShareService {
|
||||
await Note.save({ id: note.id, is_shared: 0 });
|
||||
}
|
||||
|
||||
public shareUrl(share: StateShare): string {
|
||||
return `${this.api().userContentBaseUrl()}/shares/${share.id}`;
|
||||
public shareUrl(userId: string, share: StateShare): string {
|
||||
return `${this.api().userContentBaseUrl(userId)}/shares/${share.id}`;
|
||||
}
|
||||
|
||||
public get shares() {
|
||||
|
@@ -577,7 +577,7 @@ async function initFileApi() {
|
||||
// const joplinServerAuth = {
|
||||
// "email": "admin@localhost",
|
||||
// "password": "admin",
|
||||
// "baseUrl": "http://api-joplincloud.local:22300",
|
||||
// "baseUrl": "http://api.joplincloud.local:22300",
|
||||
// "userContentBaseUrl": ""
|
||||
// }
|
||||
|
||||
|
2
packages/server/package-lock.json
generated
2
packages/server/package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/server",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/server",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start-dev": "nodemon --config nodemon.json --ext ts,js,mustache,css,tsx dist/app.js --env dev",
|
||||
|
@@ -5,6 +5,7 @@ import { unique } from '../utils/array';
|
||||
import { ErrorBadRequest, ErrorForbidden, ErrorNotFound } from '../utils/errors';
|
||||
import { setQueryParameters } from '../utils/urlUtils';
|
||||
import BaseModel, { AclAction, DeleteOptions, ValidateOptions } from './BaseModel';
|
||||
import { userIdFromUserContentUrl } from '../utils/routeUtils';
|
||||
|
||||
export default class ShareModel extends BaseModel<Share> {
|
||||
|
||||
@@ -33,6 +34,19 @@ export default class ShareModel extends BaseModel<Share> {
|
||||
}
|
||||
}
|
||||
|
||||
public checkShareUrl(share: Share, shareUrl: string) {
|
||||
if (this.baseUrl === this.userContentUrl) return; // OK
|
||||
|
||||
const userId = userIdFromUserContentUrl(shareUrl);
|
||||
const shareUserId = share.owner_id.toLowerCase();
|
||||
|
||||
if (userId.length >= 10 && shareUserId.indexOf(userId) === 0) {
|
||||
// OK
|
||||
} else {
|
||||
throw new ErrorBadRequest('Invalid origin (User Content)');
|
||||
}
|
||||
}
|
||||
|
||||
protected objectToApiOutput(object: Share): Share {
|
||||
const output: Share = {};
|
||||
|
||||
|
@@ -36,6 +36,8 @@ router.get('shares/:id', async (path: SubPath, ctx: AppContext) => {
|
||||
|
||||
const result = await renderItem(ctx, item, share);
|
||||
|
||||
ctx.models.share().checkShareUrl(share, ctx.URL.origin);
|
||||
|
||||
ctx.response.body = result.body;
|
||||
ctx.response.set('Content-Type', result.mime);
|
||||
ctx.response.set('Content-Length', result.size.toString());
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import { isValidOrigin, parseSubPath, splitItemPath } from './routeUtils';
|
||||
import { ItemAddressingType } from '../db';
|
||||
import { RouteType } from './types';
|
||||
|
||||
describe('routeUtils', function() {
|
||||
|
||||
@@ -41,7 +42,7 @@ describe('routeUtils', function() {
|
||||
}
|
||||
});
|
||||
|
||||
it('should check the request origin', async function() {
|
||||
it('should check the request origin for API URLs', async function() {
|
||||
const testCases: any[] = [
|
||||
[
|
||||
'https://example.com', // Request origin
|
||||
@@ -79,7 +80,37 @@ describe('routeUtils', function() {
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const [requestOrigin, configBaseUrl, expected] = testCase;
|
||||
expect(isValidOrigin(requestOrigin, configBaseUrl)).toBe(expected);
|
||||
expect(isValidOrigin(requestOrigin, configBaseUrl, RouteType.Api)).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('should check the request origin for User Content URLs', async function() {
|
||||
const testCases: any[] = [
|
||||
[
|
||||
'https://usercontent.local', // Request origin
|
||||
'https://usercontent.local', // Config base URL
|
||||
true,
|
||||
],
|
||||
[
|
||||
'http://usercontent.local',
|
||||
'https://usercontent.local',
|
||||
true,
|
||||
],
|
||||
[
|
||||
'https://abcd.usercontent.local',
|
||||
'https://usercontent.local',
|
||||
true,
|
||||
],
|
||||
[
|
||||
'https://bad.local',
|
||||
'https://usercontent.local',
|
||||
false,
|
||||
],
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const [requestOrigin, configBaseUrl, expected] = testCase;
|
||||
expect(isValidOrigin(requestOrigin, configBaseUrl, RouteType.UserContent)).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { baseUrl } from '../config';
|
||||
import { Item, ItemAddressingType } from '../db';
|
||||
import { Item, ItemAddressingType, Uuid } from '../db';
|
||||
import { ErrorBadRequest, ErrorForbidden, ErrorNotFound } from './errors';
|
||||
import Router from './Router';
|
||||
import { AppContext, HttpMethod, RouteType } from './types';
|
||||
@@ -153,19 +153,30 @@ export function parseSubPath(basePath: string, p: string, rawPath: string = null
|
||||
return output;
|
||||
}
|
||||
|
||||
export function isValidOrigin(requestOrigin: string, endPointBaseUrl: string): boolean {
|
||||
export function isValidOrigin(requestOrigin: string, endPointBaseUrl: string, routeType: RouteType): boolean {
|
||||
const host1 = (new URL(requestOrigin)).host;
|
||||
const host2 = (new URL(endPointBaseUrl)).host;
|
||||
return host1 === host2;
|
||||
|
||||
if (routeType === RouteType.UserContent) {
|
||||
// At this point we only check if eg usercontent.com has been accessed
|
||||
// with origin usercontent.com, or something.usercontent.com. We don't
|
||||
// check that the user ID is valid or is event present. This will be
|
||||
// done by the /share end point, which will also check that the share
|
||||
// owner ID matches the origin URL.
|
||||
if (host1 === host2) return true;
|
||||
const hostNoPrefix = host1.split('.').slice(1).join('.');
|
||||
return hostNoPrefix === host2;
|
||||
} else {
|
||||
return host1 === host2;
|
||||
}
|
||||
}
|
||||
|
||||
export function userIdFromUserContentUrl(url: string): Uuid {
|
||||
const s = (new URL(url)).hostname.split('.');
|
||||
return s[0].toLowerCase();
|
||||
}
|
||||
|
||||
export function routeResponseFormat(context: AppContext): RouteResponseFormat {
|
||||
// const rawPath = context.path;
|
||||
// if (match && match.route.responseFormat) return match.route.responseFormat;
|
||||
|
||||
// let path = rawPath;
|
||||
// if (match) path = match.basePath ? match.basePath : match.subPath.raw;
|
||||
|
||||
const path = context.path;
|
||||
return path.indexOf('api') === 0 || path.indexOf('/api') === 0 ? RouteResponseFormat.Json : RouteResponseFormat.Html;
|
||||
}
|
||||
@@ -175,7 +186,7 @@ export async function execRequest(routes: Routers, ctx: AppContext) {
|
||||
if (!match) throw new ErrorNotFound();
|
||||
|
||||
const endPoint = match.route.findEndPoint(ctx.request.method as HttpMethod, match.subPath.schema);
|
||||
if (ctx.URL && !isValidOrigin(ctx.URL.origin, baseUrl(endPoint.type))) throw new ErrorNotFound('Invalid origin', 'invalidOrigin');
|
||||
if (ctx.URL && !isValidOrigin(ctx.URL.origin, baseUrl(endPoint.type), endPoint.type)) throw new ErrorNotFound('Invalid origin', 'invalidOrigin');
|
||||
|
||||
// This is a generic catch-all for all private end points - if we
|
||||
// couldn't get a valid session, we exit now. Individual end points
|
||||
|
@@ -23,7 +23,7 @@ async function setupServices(env: Env, models: Models, config: Config): Promise<
|
||||
return output;
|
||||
}
|
||||
|
||||
export default async function(appContext: AppContext, env: Env, dbConnection: DbConnection, appLogger: ()=> LoggerWrapper) {
|
||||
export default async function(appContext: AppContext, env: Env, dbConnection: DbConnection, appLogger: ()=> LoggerWrapper): Promise<AppContext> {
|
||||
appContext.env = env;
|
||||
appContext.db = dbConnection;
|
||||
appContext.models = newModelFactory(appContext.db, config());
|
||||
@@ -32,4 +32,6 @@ export default async function(appContext: AppContext, env: Env, dbConnection: Db
|
||||
appContext.routes = { ...routes };
|
||||
|
||||
if (env === Env.Prod) delete appContext.routes['api/debug'];
|
||||
|
||||
return appContext;
|
||||
}
|
||||
|
@@ -177,24 +177,24 @@ export async function koaAppContext(options: AppContextTestOptions = null): Prom
|
||||
|
||||
// Set type to "any" because the Koa context has many properties and we
|
||||
// don't need to mock all of them.
|
||||
const appContext: any = {};
|
||||
|
||||
await setupAppContext(appContext, Env.Dev, db_, () => appLogger);
|
||||
|
||||
appContext.env = Env.Dev;
|
||||
appContext.db = db_;
|
||||
appContext.models = models();
|
||||
appContext.appLogger = () => appLogger;
|
||||
appContext.path = req.url;
|
||||
appContext.owner = owner;
|
||||
appContext.cookies = new FakeCookies();
|
||||
appContext.request = new FakeRequest(req);
|
||||
appContext.response = new FakeResponse();
|
||||
appContext.headers = { ...reqOptions.headers };
|
||||
appContext.req = req;
|
||||
appContext.query = req.query;
|
||||
appContext.method = req.method;
|
||||
appContext.redirect = () => {};
|
||||
const appContext: any = {
|
||||
...await setupAppContext({} as any, Env.Dev, db_, () => appLogger),
|
||||
env: Env.Dev,
|
||||
db: db_,
|
||||
models: models(),
|
||||
appLogger: () => appLogger,
|
||||
path: req.url,
|
||||
owner: owner,
|
||||
cookies: new FakeCookies(),
|
||||
request: new FakeRequest(req),
|
||||
response: new FakeResponse(),
|
||||
headers: { ...reqOptions.headers },
|
||||
req: req,
|
||||
query: req.query,
|
||||
method: req.method,
|
||||
redirect: () => {},
|
||||
URL: { origin: config().baseUrl },
|
||||
};
|
||||
|
||||
if (options.sessionId) {
|
||||
appContext.cookies.set('sessionId', options.sessionId);
|
||||
|
39
packages/tools/buildServerDocker.ts
Normal file
39
packages/tools/buildServerDocker.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { execCommand2, rootDir } from './tool-utils';
|
||||
|
||||
function getVersionFromTag(tagName: string): string {
|
||||
if (tagName.indexOf('server-') !== 0) throw new Error(`Invalid tag: ${tagName}`);
|
||||
const s = tagName.split('-');
|
||||
return s[1];
|
||||
}
|
||||
|
||||
function getIsPreRelease(tagName: string): boolean {
|
||||
return tagName.indexOf('-beta') > 0;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const argv = require('yargs').argv;
|
||||
if (!argv.tagName) throw new Error('--tag-name not provided');
|
||||
|
||||
const tagName = argv.tagName;
|
||||
const imageVersion = getVersionFromTag(tagName);
|
||||
const isPreRelease = getIsPreRelease(tagName);
|
||||
|
||||
process.chdir(rootDir);
|
||||
console.info(`Running from: ${process.cwd()}`);
|
||||
|
||||
console.info('tagName:', tagName);
|
||||
console.info('imageVersion:', imageVersion);
|
||||
console.info('isPreRelease:', isPreRelease);
|
||||
|
||||
await execCommand2(`docker build -t "joplin/server:${imageVersion}" -f Dockerfile.server .`);
|
||||
await execCommand2(`docker tag "joplin/server:${imageVersion}" "joplin/server:latest"`);
|
||||
await execCommand2(`docker push joplin/server:${imageVersion}`);
|
||||
|
||||
if (!isPreRelease) await execCommand2('docker push joplin/server:latest');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error');
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
@@ -15,6 +15,8 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: Poedit 2.4.3\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"POT-Creation-Date: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
|
||||
#: packages/app-desktop/bridge.js:106 packages/app-desktop/bridge.js:110
|
||||
#: packages/app-desktop/bridge.js:126 packages/app-desktop/bridge.js:134
|
||||
@@ -121,11 +123,11 @@ msgstr "Download"
|
||||
|
||||
#: packages/app-desktop/checkForUpdates.js:189
|
||||
msgid "Skip this version"
|
||||
msgstr ""
|
||||
msgstr "Spring denne version over"
|
||||
|
||||
#: packages/app-desktop/checkForUpdates.js:189
|
||||
msgid "Full changelog"
|
||||
msgstr ""
|
||||
msgstr "Komplet ændringslog"
|
||||
|
||||
#: packages/app-desktop/gui/NoteRevisionViewer.min.js:75
|
||||
#, javascript-format
|
||||
@@ -274,7 +276,6 @@ msgid "Advanced tools"
|
||||
msgstr "Avancerede indstillinger"
|
||||
|
||||
#: packages/app-desktop/gui/StatusScreen/StatusScreen.js:136
|
||||
#, fuzzy
|
||||
msgid "Export debug report"
|
||||
msgstr "Eksporter fejlrapport"
|
||||
|
||||
@@ -763,15 +764,15 @@ msgstr "Mere information"
|
||||
#: packages/app-desktop/gui/MainScreen/MainScreen.js:476
|
||||
#, javascript-format
|
||||
msgid "%s (%s) would like to share a notebook with you."
|
||||
msgstr ""
|
||||
msgstr "%s (%s) vil gerne dele en notesbog med dig."
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/MainScreen.js:478
|
||||
msgid "Accept"
|
||||
msgstr ""
|
||||
msgstr "Acceptér"
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/MainScreen.js:480
|
||||
msgid "Reject"
|
||||
msgstr ""
|
||||
msgstr "Afvis"
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/MainScreen.js:484
|
||||
msgid "Some items cannot be synchronised."
|
||||
@@ -857,9 +858,8 @@ msgid "Toggle editors"
|
||||
msgstr "Skift editorer"
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.js:16
|
||||
#, fuzzy
|
||||
msgid "Share notebook..."
|
||||
msgstr "Del note..."
|
||||
msgstr "Del notesbog..."
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/commands/toggleLayoutMoveMode.js:16
|
||||
msgid "Change application layout"
|
||||
@@ -1511,13 +1511,12 @@ msgid "You do not have any installed plugin."
|
||||
msgstr "Du har ikke installeret nogen udvidelser."
|
||||
|
||||
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js:232
|
||||
#, fuzzy
|
||||
msgid "Could not connect to plugin repository"
|
||||
msgstr "Kunne ikke installere plugin: %s"
|
||||
msgstr "Kunne ikke forbinde til plugin-lager"
|
||||
|
||||
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js:234
|
||||
msgid "Try again"
|
||||
msgstr ""
|
||||
msgstr "Prøv igen"
|
||||
|
||||
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js:242
|
||||
msgid "Plugin tools"
|
||||
@@ -1706,9 +1705,8 @@ msgstr ""
|
||||
"(Begrænsning: %s)."
|
||||
|
||||
#: packages/app-desktop/gui/ShareNoteDialog.js:141
|
||||
#, fuzzy
|
||||
msgid "Unshare note"
|
||||
msgstr "Del"
|
||||
msgstr "Del ikke længere note"
|
||||
|
||||
#: packages/app-desktop/gui/ShareNoteDialog.js:168
|
||||
msgid "Synchronising..."
|
||||
@@ -1745,19 +1743,20 @@ msgstr[0] "Kopier link til deling"
|
||||
msgstr[1] "Kopier links til deling"
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:138
|
||||
#, fuzzy
|
||||
msgid "Unshare"
|
||||
msgstr "Del"
|
||||
msgstr "Del ikke længere"
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:180
|
||||
msgid ""
|
||||
"Delete this invitation? The recipient will no longer have access to this "
|
||||
"shared notebook."
|
||||
msgstr ""
|
||||
"Vil du slette denne invitation? Modtageren vil ikke længere have adgang til "
|
||||
"denne delte notesbog."
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:194
|
||||
msgid "Add recipient:"
|
||||
msgstr ""
|
||||
msgstr "Tilføj modtager:"
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:197
|
||||
#: packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.js:28
|
||||
@@ -1767,19 +1766,19 @@ msgstr "Del"
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:206
|
||||
msgid "Recipient has not yet accepted the invitation"
|
||||
msgstr ""
|
||||
msgstr "Modtager har endnu ikke accepteret invitationen"
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:207
|
||||
msgid "Recipient has rejected the invitation"
|
||||
msgstr ""
|
||||
msgstr "Modtageren har afslået invitationen"
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:208
|
||||
msgid "Recipient has accepted the invitation"
|
||||
msgstr ""
|
||||
msgstr "Modtageren har accepteret invitationen"
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:218
|
||||
msgid "Recipients:"
|
||||
msgstr ""
|
||||
msgstr "Modtagere:"
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:230
|
||||
#, fuzzy
|
||||
@@ -1787,20 +1786,20 @@ msgid "Synchronizing..."
|
||||
msgstr "Synkroniserer..."
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:231
|
||||
#, fuzzy
|
||||
msgid "Sharing notebook..."
|
||||
msgstr "Del note..."
|
||||
msgstr "Deler notesbog..."
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:241
|
||||
msgid ""
|
||||
"Unshare this notebook? The recipients will no longer have access to its "
|
||||
"content."
|
||||
msgstr ""
|
||||
"Del ikke længere denne notesbog? Modtagerne vil ikke længere have adgang til "
|
||||
"dens indhold."
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:251
|
||||
#, fuzzy
|
||||
msgid "Share Notebook"
|
||||
msgstr "Del noter"
|
||||
msgstr "Del notesbog"
|
||||
|
||||
#: packages/app-desktop/commands/toggleSafeMode.js:18
|
||||
msgid "Toggle safe mode"
|
||||
@@ -2248,15 +2247,14 @@ msgstr ""
|
||||
"nu](%s)"
|
||||
|
||||
#: packages/server/dist/models/UserModel.js:134
|
||||
#, fuzzy
|
||||
msgid "attachment"
|
||||
msgstr "Vedhæftninger"
|
||||
msgstr "vedhæftning"
|
||||
|
||||
#: packages/server/dist/models/UserModel.js:134
|
||||
#, javascript-format
|
||||
msgid ""
|
||||
"Cannot save %s \"%s\" because it is larger than than the allowed limit (%s)"
|
||||
msgstr ""
|
||||
msgstr "Kan ikke gemme %s \"%s\" da den er større end den tilladte grænse (%s)"
|
||||
|
||||
#: packages/lib/onedrive-api-node-utils.js:46
|
||||
#, javascript-format
|
||||
@@ -2440,23 +2438,20 @@ msgid "Joplin Server URL"
|
||||
msgstr "Joplin Server URL"
|
||||
|
||||
#: packages/lib/models/Setting.js:321
|
||||
#, fuzzy
|
||||
msgid "Joplin Server email"
|
||||
msgstr "Joplin server"
|
||||
msgstr "Joplin server e-mail"
|
||||
|
||||
#: packages/lib/models/Setting.js:332
|
||||
msgid "Joplin Server password"
|
||||
msgstr "Joplin Server kodeord"
|
||||
|
||||
#: packages/lib/models/Setting.js:353
|
||||
#, fuzzy
|
||||
msgid "Joplin Cloud email"
|
||||
msgstr "Joplin server"
|
||||
msgstr "Joplin Cloud e-mail"
|
||||
|
||||
#: packages/lib/models/Setting.js:364
|
||||
#, fuzzy
|
||||
msgid "Joplin Cloud password"
|
||||
msgstr "Joplin Server kodeord"
|
||||
msgstr "Joplin Cloud adgangskode"
|
||||
|
||||
#: packages/lib/models/Setting.js:376
|
||||
msgid "Attachment download behaviour"
|
||||
@@ -2723,11 +2718,11 @@ msgstr "Brugerdefineret stylesheet til Joplin app-stil"
|
||||
|
||||
#: packages/lib/models/Setting.js:794
|
||||
msgid "Re-upload local data to sync target"
|
||||
msgstr ""
|
||||
msgstr "Upload lokal data igen til synkroniseringsmål"
|
||||
|
||||
#: packages/lib/models/Setting.js:804
|
||||
msgid "Delete local data and re-download from sync target"
|
||||
msgstr ""
|
||||
msgstr "Slet lokal data og download igen fra synkroniseringsmål"
|
||||
|
||||
#: packages/lib/models/Setting.js:809
|
||||
msgid "Automatically update the application"
|
||||
@@ -3047,9 +3042,8 @@ msgid "Encrypted items cannot be modified"
|
||||
msgstr "Krypteret emner kan ikke rettes"
|
||||
|
||||
#: packages/lib/SyncTargetJoplinCloud.js:28
|
||||
#, fuzzy
|
||||
msgid "Joplin Cloud"
|
||||
msgstr "Joplin-forum"
|
||||
msgstr "Joplin Cloud"
|
||||
|
||||
#: packages/lib/BaseApplication.js:152 packages/lib/BaseApplication.js:164
|
||||
#: packages/lib/BaseApplication.js:196
|
||||
@@ -3309,14 +3303,14 @@ msgid "HTML Directory"
|
||||
msgstr "HTML Indeks"
|
||||
|
||||
#: packages/lib/services/interop/InteropService.js:127
|
||||
#, fuzzy, javascript-format
|
||||
#, javascript-format
|
||||
msgid "Cannot load \"%s\" module for format \"%s\" and output \"%s\""
|
||||
msgstr "Kan ikke indlæse \"%s\" modul for format \"%s\" og output \"%s\""
|
||||
msgstr "Kan ikke indlæse \"%s\" modul til format \"%s\" og output \"%s\""
|
||||
|
||||
#: packages/lib/services/interop/InteropService.js:150
|
||||
#, fuzzy, javascript-format
|
||||
#, javascript-format
|
||||
msgid "Cannot load \"%s\" module for format \"%s\" and target \"%s\""
|
||||
msgstr "Kan ikke indlæse \"%s\" modul for format \"%s\" og mål \"%s\""
|
||||
msgstr "Kan ikke indlæse \"%s\" modul til format \"%s\" og mål \"%s\""
|
||||
|
||||
#: packages/lib/services/interop/InteropService.js:194
|
||||
#: packages/lib/services/interop/InteropService.js:206
|
||||
@@ -3878,6 +3872,7 @@ msgid ""
|
||||
"Runs the commands contained in the text file. There should be one command "
|
||||
"per line."
|
||||
msgstr ""
|
||||
"Afvikler kommandoerne i tekstfilen. Der skal være én kommando pr. linje."
|
||||
|
||||
#: packages/app-cli/app/command-version.js:11
|
||||
msgid "Displays version information"
|
||||
|
@@ -11,18 +11,18 @@ async function main() {
|
||||
|
||||
process.chdir(serverDir);
|
||||
const version = (await execCommand2('npm version patch')).trim();
|
||||
const versionShort = version.substr(1);
|
||||
const imageVersion = versionShort + (isPreRelease ? '-beta' : '');
|
||||
// const versionShort = version.substr(1);
|
||||
// const imageVersion = versionShort + (isPreRelease ? '-beta' : '');
|
||||
const tagName = `server-${version}`;
|
||||
|
||||
process.chdir(rootDir);
|
||||
console.info(`Running from: ${process.cwd()}`);
|
||||
// process.chdir(rootDir);
|
||||
// console.info(`Running from: ${process.cwd()}`);
|
||||
|
||||
await execCommand2(`docker build -t "joplin/server:${imageVersion}" -f Dockerfile.server .`);
|
||||
await execCommand2(`docker tag "joplin/server:${imageVersion}" "joplin/server:latest"`);
|
||||
await execCommand2(`docker push joplin/server:${imageVersion}`);
|
||||
// await execCommand2(`docker build -t "joplin/server:${imageVersion}" -f Dockerfile.server .`);
|
||||
// await execCommand2(`docker tag "joplin/server:${imageVersion}" "joplin/server:latest"`);
|
||||
// await execCommand2(`docker push joplin/server:${imageVersion}`);
|
||||
|
||||
if (!isPreRelease) await execCommand2('docker push joplin/server:latest');
|
||||
// if (!isPreRelease) await execCommand2('docker push joplin/server:latest');
|
||||
|
||||
const changelogPath = `${rootDir}/readme/changelog_server.md`;
|
||||
await completeReleaseWithChangelog(changelogPath, version, tagName, 'Server', isPreRelease);
|
||||
|
@@ -1,5 +1,12 @@
|
||||
# Joplin Server Changelog
|
||||
|
||||
## [server-v2.0.7](https://github.com/laurent22/joplin/releases/tag/server-v2.0.7) (Pre-release) - 2021-06-11T15:34:30Z
|
||||
|
||||
- New: Add navbar on login and sign up page (7a3a208)
|
||||
- New: Added option to enable or disable stack traces (5614eb9)
|
||||
- Improved: Handle custom user content URLs (a36b13d)
|
||||
- Fixed: Fixed error when creating user (594084e)
|
||||
|
||||
## [server-v2.0.6](https://github.com/laurent22/joplin/releases/tag/server-v2.0.6) (Pre-release) - 2021-06-07T17:27:27Z
|
||||
|
||||
- New: Add Stripe integration (770af6a)
|
||||
|
Reference in New Issue
Block a user