Merge branch 'main' into tweak-shortcuts

This commit is contained in:
rijkvanzanten
2020-09-29 18:04:14 -04:00
144 changed files with 30633 additions and 3484 deletions

View File

@@ -11,3 +11,11 @@ trim_trailing_whitespace = true
[{package.json,*.yml,*.yaml}]
indent_style = space
indent_size = 2
[Dockerfile]
indent_size = 2
indent_style = tab
[Makefile]
indent_size = 2
indent_style = tab

25
.github/actions/Makefile vendored Normal file
View File

@@ -0,0 +1,25 @@
SHELL=bash
version=v9.0.0-beta.1
tag=$(version)
cmd=
user=directus
registry=ghcr.io
repository=directus/next
.PHONY: build
build-images:
docker build \
--build-arg VERSION=$(version) \
--build-arg REPOSITORY=$(repository) \
-t directus:temp \
-f ./build-images/rootfs/directus/images/main/Dockerfile \
./build-images/rootfs/directus/images/main
docker tag directus:temp $(registry)/$(repository):$(version)
docker tag directus:temp $(registry)/$(repository):$(tag)
docker image rm directus:temp
test-image:
docker run --rm -it $(registry)/$(repository):$(tag) $(cmd)

View File

@@ -0,0 +1,13 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
tab_width = 2
trim_trailing_whitespace = true
[Makefile]
indent_style = tab

15
.github/actions/build-images/Dockerfile vendored Normal file
View File

@@ -0,0 +1,15 @@
FROM docker:stable
RUN \
apk update && \
apk upgrade && \
apk add bash
COPY ./rootfs/ /
RUN \
chmod +x /usr/bin/lib/argsf && \
chmod +x /usr/bin/entrypoint && \
chmod +x /usr/bin/semver
ENTRYPOINT ["entrypoint"]

42
.github/actions/build-images/action.yml vendored Normal file
View File

@@ -0,0 +1,42 @@
name: "Build and publish Directus images"
description: "GitHub Action to publish Directus container images."
branding:
icon: archive
color: gray-dark
inputs:
repository:
description: "Repository name"
required: true
registry:
description: "Registry"
required: false
default: ghcr.io
username:
description: "Registry user"
required: true
password:
description: "Registry password"
required: true
version:
description: "Version"
required: true
push:
description: "Push"
required: false
default: "false"
runs:
using: "docker"
image: "Dockerfile"
args:
- --registry
- ${{ inputs.registry }}
- --repository
- ${{ inputs.repository }}
- --username
- ${{ inputs.username }}
- --password
- ${{ inputs.password }}
- --version
- ${{ inputs.version }}
- --push
- ${{ inputs.push }}

View File

@@ -0,0 +1,2 @@
.dockerignore
Dockerfile

View File

@@ -0,0 +1,13 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
tab_width = 2
trim_trailing_whitespace = true
[Makefile]
indent_style = tab

View File

@@ -0,0 +1,94 @@
#
# Builder
#
FROM node:14-alpine AS builder
ARG VERSION
RUN \
apk update && \
apk upgrade && \
apk add jq
WORKDIR /directus
COPY package.json .
RUN \
jq ".dependencies.directus = \"^${VERSION}\"" package.json > updated.json && \
mv updated.json package.json
RUN cat package.json
#
# Image
#
FROM node:14-alpine
ARG VERSION
ARG REPOSITORY=directus/directus
LABEL directus.version="${VERSION}"
LABEL org.opencontainers.image.source https://github.com/${REPOSITORY}
ENV \
PORT="41201" \
PUBLIC_URL="/" \
DB_CLIENT="sqlite3" \
DB_FILENAME="/directus/database/database.sqlite" \
RATE_LIMITER_ENABLED="false" \
RATE_LIMITER_STORE="memory" \
RATE_LIMITER_POINTS="25" \
RATE_LIMITER_DURATION="1" \
CACHE_ENABLED="false" \
STORAGE_LOCATIONS="local" \
STORAGE_LOCAL_PUBLIC_URL="/uploads" \
STORAGE_LOCAL_DRIVER="local" \
STORAGE_LOCAL_ROOT="/directus/uploads" \
ACCESS_TOKEN_TTL="15m" \
REFRESH_TOKEN_TTL="7d" \
REFRESH_TOKEN_COOKIE_SECURE="false" \
REFRESH_TOKEN_COOKIE_SAME_SITE="lax" \
OAUTH_PROVIDERS="" \
EXTENSIONS_PATH="/directus/extensions" \
EMAIL_FROM="no-reply@directus.io" \
EMAIL_TRANSPORT="sendmail" \
EMAIL_SENDMAIL_NEW_LINE="unix" \
EMAIL_SENDMAIL_PATH="/usr/sbin/sendmail"
RUN \
apk update && \
apk upgrade && \
apk add bash ssmtp util-linux
SHELL ["/bin/bash", "-c"]
WORKDIR /directus
# Global requirements
RUN npm install -g yargs pino pino-colada
# Install Directus
COPY --from=builder /directus/package.json .
RUN npm install
# Copy files
COPY ./rootfs /
RUN chmod +x /usr/bin/entrypoint && chmod +x /usr/bin/print
# Create directories
RUN \
mkdir -p extensions/displays && \
mkdir -p extensions/interfaces && \
mkdir -p extensions/layouts && \
mkdir -p extensions/modules && \
mkdir -p database && \
mkdir -p uploads
EXPOSE 41201
VOLUME \
/directus/database \
/directus/extensions \
/directus/uploads
ENTRYPOINT ["entrypoint"]

View File

@@ -0,0 +1,22 @@
{
"name": "directus-project",
"version": "1.0.0",
"description": "Directus Project",
"main": "index.js",
"scripts": {},
"keywords": [],
"author": "",
"license": "MIT",
"dependencies": {
"@keyv/redis": "^2.1.2",
"directus": "^9.0.0-beta.1",
"ioredis": "^4.17.3",
"memcached": "^2.2.2",
"mssql": "^6.2.2",
"mysql": "^2.18.1",
"oracledb": "^5.0.0",
"pg": "^8.3.3",
"sqlite3": "^5.0.0",
"yargs": "^16.0.3"
}
}

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env bash
set -e
function seed() {
# TODO: move users to a separate check, outside database installation
local show=false
local email=${DIRECTUS_ADMIN_EMAIL:-"admin@example.com"}
local password=${DIRECTUS_ADMIN_PASSWORD:-""}
if [ "${password}" == "" ] ; then
password=$(node -e 'console.log(require("nanoid").nanoid(12))')
show=true
fi
print --level=info "Creating administrator role"
local role=$(npx directus roles create --name Administrator --admin)
print --level=info "Creating administrator user"
local user=$(npx directus users create --email "${email}" --password "${password}" --role "${role}")
if [ "${show}" == "true" ] ; then
print --level=info --stdin <<MSG
>
> Email: $email
> Password: $password
>
MSG
else
print --level=info --stdin <<MSG
>
> Email: $email
> Password: <env>
>
MSG
fi
}
function bootstrap() {
local warn=false
if [ "${KEY}" == "" ] ; then
export KEY=$(uuidgen)
warn=true
fi
if [ "${SECRET}" == "" ] ; then
export SECRET=$(node -e 'console.log(require("nanoid").nanoid(32))')
warn=true
fi
if [ "${warn}" == "true" ] ; then
print --level=warn --stdin <<WARN
>
> WARNING!
>
> The KEY and SECRET environment variables are not set.
> Some temporar
y variables were generated to fill the gap,
> but in production this is going to cause problems.
>
> Please refer to the docs at https://docs.directus.io/
> on how and why to configure them properly
>
WARN
fi
# Install database if using sqlite and file doesn't exist
if [ "${DB_CLIENT}" == "sqlite3" ] ; then
if [ "${DB_FILENAME}" == "" ] ; then
print --level=error "Missing DB_FILENAME environment variable"
exit 1
fi
if [ ! -f "${DB_FILENAME}" ] ; then
mkdir -p $(dirname ${DB_FILENAME})
fi
fi
should_seed=false
set +e
npx directus database install 2>&1 /dev/null
if [ "$?" == "0" ] ; then
print --level=info "Database installed"
should_seed=true
fi
set -e
if [ "${should_seed}" == "true" ] ; then
seed
fi
}
command=""
if [ $# -eq 0 ] ; then
command="start"
elif [ "${1}" == "bash" ] || [ "${1}" == "shell" ] ; then
shift
exec bash $@
elif [ "${1}" == "command" ] ; then
shift
exec $@
else
command="${1}"
shift
fi
bootstrap
exec npx directus "${command}" $@

View File

@@ -0,0 +1,48 @@
#!/usr/bin/env node
// Workarounds?
process.env.NODE_PATH = "/usr/local/lib/node_modules";
require("module").Module._initPaths();
/**
* Read lines from stdin
*/
async function readlines() {
const chunks = [];
for await (const chunk of process.stdin) {
chunks.push(chunk);
}
const lines = chunks.join("").split("\n");
lines.pop();
return lines;
}
(async function () {
// Logger
const yargs = require("yargs");
const logger = require("pino")({
prettyPrint: process.env.LOG_STYLE !== "raw",
prettifier: require("pino-colada"),
level: process.env.LOG_LEVEL || "info",
});
function write(...message) {
if (level in logger) {
logger[level](...message);
} else {
logger.info(...message);
}
}
const args = yargs.argv;
const level = args.level || "info";
const stdin = args.stdin || false;
if (stdin) {
const lines = await readlines();
lines.forEach((line) => write(line));
} else {
write(...args._);
}
})();

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env bash
set -e
root=$(dirname ${0})
source ${root}/lib/argsf
#
# Makes a set of tags
#
function make_tags() {
local prefix=""
local version=${1}
semver get major ${version} > /dev/null 2>&1
if [ "$?" != "0" ]; then
echo "${version}"
else
if [ "${version:0:1}" == "v" ]; then
prefix="v"
fi
major="$(semver get major ${version})"
minor="${major}.$(semver get minor ${version})"
patch="${minor}.$(semver get patch ${version})"
prerel="$(semver get prerel ${version})"
if [ "${prerel}" == "" ]; then
is_prerel=false
else
is_prerel=true
fi
build="$(semver get build ${version})"
if [ "${build}" == "" ]; then
is_build=false
else
is_build=true
fi
if [ "${is_prerel}" == "true" ]; then
echo "${prefix}${major}-${prerel}"
echo "${prefix}${minor}-${prerel}"
echo "${prefix}${patch}-${prerel}"
if [ "${is_build}" == "true" ]; then
echo "${prefix}${major}-${prerel}-${build}"
fi
else
echo "${prefix}${major}"
echo "${prefix}${minor}"
echo "${prefix}${patch}"
if [ "${is_build}" == "true" ]; then
echo "${prefix}${patch}-${build}"
fi
fi
fi
}
#
# Build script
#
function main() {
username=$(argument username)
password=$(argument password)
push=$(argument push "false")
latest=$(argument latest "false")
registry=$(argument registry "ghcr.io")
registry=$(echo "${registry}" | tr '[:upper:]' '[:lower:]')
repository=$(argument repository "directus/next")
repository=$(echo "${repository}" | tr '[:upper:]' '[:lower:]')
version=$(argument version "")
context=$(argument context ".")
# Normalize tag
if [ "${version}" == "" ]; then
version=${GITHUB_REF##*/}
else
version=${version##*/}
fi
if [ "${version}" == "" ]; then
version=$(echo ${GITHUB_SHA:-"000000000000"} | cut -c1-12)
fi
tags=$(make_tags ${version})
echo "Tags = ${tags}"
# build image
docker build \
-t directus:main \
--build-arg VERSION=${version} \
--build-arg REPOSITORY=${repository} \
/directus/images/main
# login into registry
docker login -u "${username}" -p "${password}" "${registry}"
# Push latest
# TODO: check if it's really the latest
if [ "${latest}" == "true" ]; then
fqin="${registry}/${repository}:latest"
echo "Tagging ${fqin}"
docker tag directus:main ${fqin}
if [ "${push}" == "true" ]; then
echo "Pushing tag ${fqin}"
docker push "${fqin}"
fi
fi
# Push tags
for tag in $tags
do
tag=$(echo "${tag}" | tr '[:upper:]' '[:lower:]')
fqin="${registry}/${repository}:latest"
echo "Tagging ${fqin}"
docker tag directus:main "${registry}/${repository}:${tag}"
if [ "${push}" == "true" ]; then
echo "Pushing tag ${fqin}"
docker push "${registry}/${repository}:${tag}"
fi
done
echo "Finished."
exit $?
}
main
exit $?

View File

@@ -0,0 +1,98 @@
#
# Arguments and Flags (argsf)
# This is meant to work with bash shell
# To use, source this file into your bash scripts
#
# Implemented by João Biondo <wolfulus@gmail.com>
# https://github.com/WoLfulus/argsf
#
declare _ARGCOUNT=$#
declare _ARGDATA=("$@")
declare -A _ARGMAP
declare -A _FLAGMAP
for ((_arg_index_key=1;_arg_index_key<=$#;_arg_index_key++))
do
_arg_index_value=$(expr $_arg_index_key + 1)
_arg_key=${!_arg_index_key}
_arg_value=${!_arg_index_value}
if [[ $_arg_key == *"--"* ]]; then
if [[ $_arg_key == *" "* ]]; then
continue
fi
_arg_name="${_arg_key:2}"
_FLAGMAP[${_arg_name}]=1
if [[ $_arg_value != *"--"* ]] || [[ $_arg_value == *" "* ]] ; then
_ARGMAP[${_arg_name}]="$_arg_value"
else
_ARGMAP[${_arg_name}]=""
fi
fi
done
function _argument() {
if test "${_ARGMAP[${ARG_NAME}]+isset}" ; then
echo ${_ARGMAP[${ARG_NAME}]}
else
if [ ${ARG_DEFAULT} -eq 0 ]; then
echo "Error: required argument '--${ARG_NAME}' not specified" 1>&2
exit 1
else
echo ${ARG_DEFAULT_VALUE}
fi
fi
}
function argument() {
if [ $# -eq 1 ]; then
ARG_NAME="$1" ARG_DEFAULT=0 ARG_DEFAULT_VALUE= _argument "${_ARGUMENT_DATA}"
elif [ $# -eq 2 ]; then
ARG_NAME="$1" ARG_DEFAULT=1 ARG_DEFAULT_VALUE="$2" _argument "${_ARGUMENT_DATA}"
else
echo "argument: invalid number of arguments" 1>&2
return 1
fi
return 0
}
function flage() {
if [ $# -eq 1 ]; then
if [[ ${_FLAGMAP[$1]} ]] ; then
echo "true"
return 0
elif [[ ${_FLAGMAP[no-$1]} ]] ; then
echo "false"
return 0
else
echo "true"
return 0
fi
else
echo "flag: invalid number of arguments" 1>&2
return 1
fi
}
function flagd() {
if [ $# -eq 1 ]; then
if [[ ${_FLAGMAP[$1]} ]] ; then
echo "true"
return 0
elif [[ ${_FLAGMAP[no-$1]} ]] ; then
echo "false"
return 0
else
echo "false"
return 0
fi
else
echo "flag: invalid number of arguments" 1>&2
return 1
fi
}
function flag() {
flagd $1
return $?
}

View File

@@ -0,0 +1,284 @@
#!/usr/bin/env bash
#
# Copyright (c) 2014-2015 François Saint-Jacques <fsaintjacques@gmail.com>
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation; either version 3, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
# PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# this program. If not, see <http://www.gnu.org/licenses/>.
#
set -o errexit -o nounset -o pipefail
NAT='0|[1-9][0-9]*'
ALPHANUM='[0-9]*[A-Za-z-][0-9A-Za-z-]*'
IDENT="$NAT|$ALPHANUM"
FIELD='[0-9A-Za-z-]+'
SEMVER_REGEX="\
^[vV]?\
($NAT)\\.($NAT)\\.($NAT)\
(\\-(${IDENT})(\\.(${IDENT}))*)?\
(\\+${FIELD}(\\.${FIELD})*)?$"
PROG=semver
PROG_VERSION="3.0.0"
USAGE="\
Usage:
$PROG bump (major|minor|patch|release|prerel <prerel>|build <build>) <version>
$PROG compare <version> <other_version>
$PROG get (major|minor|patch|release|prerel|build) <version>
$PROG --help
$PROG --version
Arguments:
<version> A version must match the following regular expression:
\"${SEMVER_REGEX}\"
In English:
-- The version must match X.Y.Z[-PRERELEASE][+BUILD]
where X, Y and Z are non-negative integers.
-- PRERELEASE is a dot separated sequence of non-negative integers and/or
identifiers composed of alphanumeric characters and hyphens (with
at least one non-digit). Numeric identifiers must not have leading
zeros. A hyphen (\"-\") introduces this optional part.
-- BUILD is a dot separated sequence of identifiers composed of alphanumeric
characters and hyphens. A plus (\"+\") introduces this optional part.
<other_version> See <version> definition.
<prerel> A string as defined by PRERELEASE above.
<build> A string as defined by BUILD above.
Options:
-v, --version Print the version of this tool.
-h, --help Print this help message.
Commands:
bump Bump by one of major, minor, patch; zeroing or removing
subsequent parts. \"bump prerel\" sets the PRERELEASE part and
removes any BUILD part. \"bump build\" sets the BUILD part.
\"bump release\" removes any PRERELEASE or BUILD parts.
The bumped version is written to stdout.
compare Compare <version> with <other_version>, output to stdout the
following values: -1 if <other_version> is newer, 0 if equal, 1 if
older. The BUILD part is not used in comparisons.
get Extract given part of <version>, where part is one of major, minor,
patch, prerel, build, or release.
See also:
https://semver.org -- Semantic Versioning 2.0.0"
function error {
echo -e "$1" >&2
exit 1
}
function usage-help {
error "$USAGE"
}
function usage-version {
echo -e "${PROG}: $PROG_VERSION"
exit 0
}
function validate-version {
local version=$1
if [[ "$version" =~ $SEMVER_REGEX ]]; then
# if a second argument is passed, store the result in var named by $2
if [ "$#" -eq "2" ]; then
local major=${BASH_REMATCH[1]}
local minor=${BASH_REMATCH[2]}
local patch=${BASH_REMATCH[3]}
local prere=${BASH_REMATCH[4]}
local build=${BASH_REMATCH[8]}
eval "$2=(\"$major\" \"$minor\" \"$patch\" \"$prere\" \"$build\")"
else
echo "$version"
fi
else
error "version $version does not match the semver scheme 'X.Y.Z(-PRERELEASE)(+BUILD)'. See help for more information."
fi
}
function is-nat {
[[ "$1" =~ ^($NAT)$ ]]
}
function is-null {
[ -z "$1" ]
}
function order-nat {
[ "$1" -lt "$2" ] && { echo -1 ; return ; }
[ "$1" -gt "$2" ] && { echo 1 ; return ; }
echo 0
}
function order-string {
[[ $1 < $2 ]] && { echo -1 ; return ; }
[[ $1 > $2 ]] && { echo 1 ; return ; }
echo 0
}
# given two (named) arrays containing NAT and/or ALPHANUM fields, compare them
# one by one according to semver 2.0.0 spec. Return -1, 0, 1 if left array ($1)
# is less-than, equal, or greater-than the right array ($2). The longer array
# is considered greater-than the shorter if the shorter is a prefix of the longer.
#
function compare-fields {
local l="$1[@]"
local r="$2[@]"
local leftfield=( "${!l}" )
local rightfield=( "${!r}" )
local left
local right
local i=$(( -1 ))
local order=$(( 0 ))
while true
do
[ $order -ne 0 ] && { echo $order ; return ; }
: $(( i++ ))
left="${leftfield[$i]}"
right="${rightfield[$i]}"
is-null "$left" && is-null "$right" && { echo 0 ; return ; }
is-null "$left" && { echo -1 ; return ; }
is-null "$right" && { echo 1 ; return ; }
is-nat "$left" && is-nat "$right" && { order=$(order-nat "$left" "$right") ; continue ; }
is-nat "$left" && { echo -1 ; return ; }
is-nat "$right" && { echo 1 ; return ; }
{ order=$(order-string "$left" "$right") ; continue ; }
done
}
# shellcheck disable=SC2206 # checked by "validate"; ok to expand prerel id's into array
function compare-version {
local order
validate-version "$1" V
validate-version "$2" V_
# compare major, minor, patch
local left=( "${V[0]}" "${V[1]}" "${V[2]}" )
local right=( "${V_[0]}" "${V_[1]}" "${V_[2]}" )
order=$(compare-fields left right)
[ "$order" -ne 0 ] && { echo "$order" ; return ; }
# compare pre-release ids when M.m.p are equal
local prerel="${V[3]:1}"
local prerel_="${V_[3]:1}"
local left=( ${prerel//./ } )
local right=( ${prerel_//./ } )
# if left and right have no pre-release part, then left equals right
# if only one of left/right has pre-release part, that one is less than simple M.m.p
[ -z "$prerel" ] && [ -z "$prerel_" ] && { echo 0 ; return ; }
[ -z "$prerel" ] && { echo 1 ; return ; }
[ -z "$prerel_" ] && { echo -1 ; return ; }
# otherwise, compare the pre-release id's
compare-fields left right
}
function command-bump {
local new; local version; local sub_version; local command;
case $# in
2) case $1 in
major|minor|patch|release) command=$1; version=$2;;
*) usage-help;;
esac ;;
3) case $1 in
prerel|build) command=$1; sub_version=$2 version=$3 ;;
*) usage-help;;
esac ;;
*) usage-help;;
esac
validate-version "$version" parts
# shellcheck disable=SC2154
local major="${parts[0]}"
local minor="${parts[1]}"
local patch="${parts[2]}"
local prere="${parts[3]}"
local build="${parts[4]}"
case "$command" in
major) new="$((major + 1)).0.0";;
minor) new="${major}.$((minor + 1)).0";;
patch) new="${major}.${minor}.$((patch + 1))";;
release) new="${major}.${minor}.${patch}";;
prerel) new=$(validate-version "${major}.${minor}.${patch}-${sub_version}");;
build) new=$(validate-version "${major}.${minor}.${patch}${prere}+${sub_version}");;
*) usage-help ;;
esac
echo "$new"
exit 0
}
function command-compare {
local v; local v_;
case $# in
2) v=$(validate-version "$1"); v_=$(validate-version "$2") ;;
*) usage-help ;;
esac
set +u # need unset array element to evaluate to null
compare-version "$v" "$v_"
exit 0
}
# shellcheck disable=SC2034
function command-get {
local part version
if [[ "$#" -ne "2" ]] || [[ -z "$1" ]] || [[ -z "$2" ]]; then
usage-help
exit 0
fi
part="$1"
version="$2"
validate-version "$version" parts
local major="${parts[0]}"
local minor="${parts[1]}"
local patch="${parts[2]}"
local prerel="${parts[3]:1}"
local build="${parts[4]:1}"
local release="${major}.${minor}.${patch}"
case "$part" in
major|minor|patch|release|prerel|build) echo "${!part}" ;;
*) usage-help ;;
esac
exit 0
}
case $# in
0) echo "Unknown command: $*"; usage-help;;
esac
case $1 in
--help|-h) echo -e "$USAGE"; exit 0;;
--version|-v) usage-version ;;
bump) shift; command-bump "$@";;
get) shift; command-get "$@";;
compare) shift; command-compare "$@";;
*) echo "Unknown arguments: $*"; usage-help;;
esac

22
.github/workflows/build-images.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: build-images
on:
release:
types:
- published
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Build
uses: ./.github/actions/build-images
with:
registry: "ghcr.io"
repository: "${{ github.repository }}"
username: "${{ secrets.REGISTRY_USERNAME }}"
password: "${{ secrets.REGISTRY_PASSWORD }}"
version: "${{ github.ref }}"
push: "true"

2
.gitignore vendored
View File

@@ -2,6 +2,8 @@
node_modules
.vs_code
.env
.secrets
npm-debug.log
lerna-debug.log
.nova
*.code-workspace

25939
api/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "directus",
"version": "9.0.0-beta.1",
"version": "9.0.0-beta.2",
"license": "GPL-3.0-only",
"homepage": "https://github.com/directus/next#readme",
"description": "Directus is a real-time API and App dashboard for managing SQL database content.",
@@ -64,7 +64,7 @@
"example.env"
],
"dependencies": {
"@directus/app": "^9.0.0-beta.1",
"@directus/app": "^9.0.0-beta.2",
"@directus/format-title": "^3.2.0",
"@slynova/flydrive": "^1.0.2",
"@slynova/flydrive-gcs": "^1.0.2",
@@ -79,6 +79,7 @@
"commander": "^5.1.0",
"cookie-parser": "^1.4.5",
"cors": "^2.8.5",
"date-fns": "^2.16.1",
"dotenv": "^8.2.0",
"eventemitter2": "^6.4.3",
"execa": "^4.0.3",
@@ -97,7 +98,7 @@
"jsonwebtoken": "^8.5.1",
"keyv": "^4.0.1",
"knex": "^0.21.4",
"knex-schema-inspector": "0.0.12",
"knex-schema-inspector": "0.0.20",
"liquidjs": "^9.14.1",
"lodash": "^4.17.19",
"macos-release": "^2.4.1",

View File

@@ -0,0 +1,15 @@
export default async function rolesCreate(collection: string) {
const database = require('../../../database/index').default;
if (!collection) {
console.error('Collection is required');
process.exit(1);
}
const records = await database(collection).count('*', { as: 'count' });
const count = Number(records[0].count);
console.log(count);
database.destroy();
}

View File

@@ -1,6 +1,6 @@
export default async function rolesCreate({ name, admin }: any) {
const database = require('../../../database/index').default;
const RolesService = require('../../../services/roles').default;
const { RolesService } = require('../../../services/roles');
if (!name) {
console.error('Name is required');

View File

@@ -1,4 +1,5 @@
import logger from '../../logger';
import { Express } from 'express';
export default async function start() {
const { default: env } = require('../../env');
@@ -6,11 +7,24 @@ export default async function start() {
await validateDBConnection();
const app = require('../../app').default;
const app: Express = require('../../app').default;
const port = env.PORT;
app.listen(port, () => {
const server = app.listen(port, () => {
logger.info(`Server started at port ${port}`);
});
const signals: NodeJS.Signals[] = ['SIGHUP', 'SIGINT', 'SIGTERM'];
signals.forEach((signal) => {
process.on(signal, () =>
server.close((err) => {
if (err) {
logger.error(err.message, { err });
return;
}
logger.info('Server stopped.');
})
);
});
}

View File

@@ -1,6 +1,6 @@
export default async function usersCreate({ email, password, role }: any) {
const database = require('../../../database/index').default;
const UsersService = require('../../../services/users').default;
const { UsersService } = require('../../../services/users');
if (!email || !password || !role) {
console.error('Email, password, role are required');

View File

@@ -10,6 +10,7 @@ import dbInstall from './commands/database/install';
import dbMigrate from './commands/database/migrate';
import usersCreate from './commands/users/create';
import rolesCreate from './commands/roles/create';
import count from './commands/count';
program.name('directus').usage('[command] [options]');
program.version(pkg.version, '-v, --version');
@@ -51,4 +52,9 @@ rolesCommand
.option('--admin', `whether or not the role has admin access`)
.action(rolesCreate);
program
.command('count <collection>')
.description('Count the amount of items in a given collection')
.action(count);
program.parse(process.argv);

View File

@@ -3,13 +3,14 @@ import session from 'express-session';
import asyncHandler from 'express-async-handler';
import Joi from 'joi';
import grant from 'grant';
import getGrantConfig from '../utils/get-grant-config';
import getEmailFromProfile from '../utils/get-email-from-profile';
import { InvalidPayloadException } from '../exceptions/invalid-payload';
import ms from 'ms';
import cookieParser from 'cookie-parser';
import env from '../env';
import { UsersService, AuthenticationService } from '../services';
import grantConfig from '../grant';
import { RouteNotFoundException } from '../exceptions';
const router = Router();
@@ -203,19 +204,41 @@ router.post(
})
);
router.get('/oauth', asyncHandler(async (req, res, next) => {
const providers = env.OAUTH_PROVIDERS.split(',');
res.locals.payload = { data: providers };
return next();
}));
router.use(
'/sso',
'/oauth',
session({ secret: env.SECRET as string, saveUninitialized: false, resave: false })
);
router.use(grant.express()(getGrantConfig()));
router.get('/oauth/:provider', asyncHandler(async(req, res, next) => {
const config = { ...grantConfig };
delete config.defaults;
const availableProviders = Object.keys(config);
if (availableProviders.includes(req.params.provider) === false) {
throw new RouteNotFoundException(`/auth/oauth/${req.params.provider}`);
}
if (req.query?.redirect && req.session) {
req.session.redirect = req.query.redirect;
}
next();
}));
router.use(grant.express()(grantConfig));
/**
* @todo allow json / cookie mode in SSO
*/
router.get(
'/sso/:provider/callback',
'/oauth/:provider/callback',
asyncHandler(async (req, res, next) => {
const redirect = req.session?.redirect;
const accountability = {
ip: req.ip,
userAgent: req.get('user-agent'),
@@ -226,17 +249,29 @@ router.get(
accountability: accountability,
});
const email = getEmailFromProfile(req.params.provider, req.session!.grant.response.profile);
const email = getEmailFromProfile(req.params.provider, req.session!.grant.response?.profile);
const { accessToken, refreshToken, expires } = await authenticationService.authenticate(
email
);
req.session?.destroy(() => { });
res.locals.payload = {
data: { access_token: accessToken, refresh_token: refreshToken, expires },
};
const { accessToken, refreshToken, expires } = await authenticationService.authenticate({ email });
return next();
if (redirect) {
res.cookie('directus_refresh_token', refreshToken, {
httpOnly: true,
maxAge: ms(env.REFRESH_TOKEN_TTL as string),
secure: env.REFRESH_TOKEN_COOKIE_SECURE === 'true' ? true : false,
sameSite:
(env.REFRESH_TOKEN_COOKIE_SAME_SITE as 'lax' | 'strict' | 'none') || 'strict',
});
return res.redirect(redirect);
} else {
res.locals.payload = {
data: { access_token: accessToken, refresh_token: refreshToken, expires },
};
return next();
}
})
);

View File

@@ -113,7 +113,7 @@ router.post(
try {
const record = await service.readByKey(keys as any, req.sanitizedQuery);
res.locals.payload = {
data: res.locals.savedFiles.length === 1 ? record[0] : record || null,
data: res.locals.savedFiles.length === 1 ? record![0] : record || null,
};
} catch (error) {
if (error instanceof ForbiddenException) {

View File

@@ -1,207 +1,233 @@
import { AST, NestedCollectionAST } from '../types/ast';
import { clone, uniq, pick } from 'lodash';
import { clone, cloneDeep, uniq, pick } from 'lodash';
import database from './index';
import SchemaInspector from 'knex-schema-inspector';
import { Query, Item } from '../types';
import { PayloadService } from '../services/payload';
import applyQuery from '../utils/apply-query';
import Knex from 'knex';
import Knex, { QueryBuilder } from 'knex';
type RunASTOptions = {
query?: AST['query'];
knex?: Knex;
child?: boolean;
};
export default async function runAST(ast: AST, options?: RunASTOptions) {
export default async function runAST(originalAST: AST, options?: RunASTOptions): Promise<null | Item | Item[]> {
const ast = cloneDeep(originalAST);
const query = options?.query || ast.query;
const knex = options?.knex || database;
// Retrieve the database columns to select in the current AST
const { columnsToSelect, primaryKeyField, nestedCollectionASTs } = await parseCurrentLevel(ast, knex);
// The actual knex query builder instance. This is a promise that resolves with the raw items from the db
const dbQuery = await getDBQuery(knex, ast.name, columnsToSelect, query, primaryKeyField);
const rawItems: Item | Item[] = await dbQuery;
if (!rawItems) return null;
// Run the items through the special transforms
const payloadService = new PayloadService(ast.name, { knex });
let items = await payloadService.processValues('read', rawItems);
if (!items || items.length === 0) return items;
// Apply the `_in` filters to the nested collection batches
const nestedASTs = applyParentFilters(nestedCollectionASTs, items);
for (const nestedAST of nestedASTs) {
let tempLimit: number | null = null;
// Nested o2m-items are fetched from the db in a single query. This means that we're fetching
// all nested items for all parent items at once. Because of this, we can't limit that query
// to the "standard" item limit. Instead of _n_ nested items per parent item, it would mean
// that there's _n_ items, which are then divided on the parent items. (no good)
if (isO2M(nestedAST) && typeof nestedAST.query.limit === 'number') {
tempLimit = nestedAST.query.limit;
nestedAST.query.limit = -1;
}
let nestedItems = await runAST(nestedAST, { knex, child: true });
if (nestedItems) {
// Merge all fetched nested records with the parent items
items = mergeWithParentItems(nestedItems, items, nestedAST, tempLimit);
}
}
// During the fetching of data, we have to inject a couple of required fields for the child nesting
// to work (primary / foreign keys) even if they're not explicitly requested. After all fetching
// and nesting is done, we parse through the output structure, and filter out all non-requested
// fields
if (options?.child !== true) {
items = removeTemporaryFields(items, originalAST);
}
return items;
}
async function parseCurrentLevel(ast: AST, knex: Knex) {
const schemaInspector = SchemaInspector(knex);
const toplevelFields: string[] = [];
const tempFields: string[] = [];
const nestedCollections: NestedCollectionAST[] = [];
const primaryKeyField = await schemaInspector.primary(ast.name);
const columnsInCollection = (await schemaInspector.columns(ast.name)).map(
({ column }) => column
);
const payloadService = new PayloadService(ast.name, { knex });
const columnsToSelect: string[] = [];
const nestedCollectionASTs: NestedCollectionAST[] = [];
for (const child of ast.children) {
if (child.type === 'field') {
if (columnsInCollection.includes(child.name) || child.name === '*') {
toplevelFields.push(child.name);
columnsToSelect.push(child.name);
}
continue;
}
if (!child.relation) continue;
const m2o = isM2O(child);
if (m2o) {
toplevelFields.push(child.relation.many_field);
columnsToSelect.push(child.relation.many_field);
}
nestedCollections.push(child);
nestedCollectionASTs.push(child);
}
/** Always fetch primary key in case there's a nested relation that needs it */
if (toplevelFields.includes(primaryKeyField) === false) {
tempFields.push(primaryKeyField);
if (columnsToSelect.includes(primaryKeyField) === false) {
columnsToSelect.push(primaryKeyField);
}
let dbQuery = knex.select([...toplevelFields, ...tempFields]).from(ast.name);
return { columnsToSelect, nestedCollectionASTs, primaryKeyField };
}
// Query defaults
query.limit = typeof query.limit === 'number' ? query.limit : 100;
async function getDBQuery(knex: Knex, table: string, columns: string[], query: Query, primaryKeyField: string): Promise<QueryBuilder> {
let dbQuery = knex.select(columns.map((column) => `${table}.${column}`)).from(table);
if (query.limit === -1) {
delete query.limit;
const queryCopy = clone(query);
queryCopy.limit = typeof queryCopy.limit === 'number' ? queryCopy.limit : 100;
if (queryCopy.limit === -1) {
delete queryCopy.limit;
}
query.sort = query.sort || [{ column: primaryKeyField, order: 'asc' }];
await applyQuery(ast.name, dbQuery, query);
await applyQuery(table, dbQuery, queryCopy);
let results: Item[] = await dbQuery;
return dbQuery;
}
results = await payloadService.processValues('read', results);
function applyParentFilters(nestedCollectionASTs: NestedCollectionAST[], parentItem: Item | Item[]) {
const parentItems = Array.isArray(parentItem) ? parentItem : [parentItem];
for (const batch of nestedCollections) {
const m2o = isM2O(batch);
for (const nestedAST of nestedCollectionASTs) {
if (!nestedAST.relation) continue;
let batchQuery: Query = {};
let tempField: string;
let tempLimit: number;
if (m2o) {
// Make sure we always fetch the nested items primary key field to ensure we have the key to match the item by
const toplevelFields = batch.children
.filter(({ type }) => type === 'field')
.map(({ name }) => name);
if (
toplevelFields.includes(batch.relation.one_primary) === false &&
toplevelFields.includes('*') === false
) {
tempField = batch.relation.one_primary;
batch.children.push({ type: 'field', name: batch.relation.one_primary });
}
batchQuery = {
...batch.query,
if (isM2O(nestedAST)) {
nestedAST.query = {
...nestedAST.query,
filter: {
...(batch.query.filter || {}),
[batch.relation.one_primary]: {
_in: uniq(results.map((res) => res[batch.relation.many_field])).filter(
...(nestedAST.query.filter || {}),
[nestedAST.relation.one_primary]: {
_in: uniq(parentItems.map((res) => res[nestedAST.relation.many_field])).filter(
(id) => id
),
},
},
};
}
}
}
} else {
// o2m
// Make sure we always fetch the related m2o field to ensure we have the foreign key to
// match the items by
const toplevelFields = batch.children
.filter(({ type }) => type === 'field')
.map(({ name }) => name);
if (
toplevelFields.includes(batch.relation.many_field) === false &&
toplevelFields.includes('*') === false
) {
tempField = batch.relation.many_field;
batch.children.push({ type: 'field', name: batch.relation.many_field });
const relatedM2OisFetched = !!nestedAST.children.find((child) => {
return child.type === 'field' && child.name === nestedAST.relation.many_field
});
if (relatedM2OisFetched === false) {
nestedAST.children.push({ type: 'field', name: nestedAST.relation.many_field });
}
batchQuery = {
...batch.query,
nestedAST.query = {
...nestedAST.query,
filter: {
...(batch.query.filter || {}),
[batch.relation.many_field]: {
_in: uniq(results.map((res) => res[batch.parentKey])).filter((id) => id),
},
},
};
...(nestedAST.query.filter || {}),
[nestedAST.relation.many_field]: {
_in: uniq(parentItems.map((res) => res[nestedAST.parentKey])).filter((id) => id),
}
}
}
}
}
/**
* The nested queries are done with a WHERE m2o IN (pk, pk, pk) query. We have to remove
* LIMIT from that equation to ensure we limit `n` items _per parent record_ instead of
* `n` items in total. This limit will then be re-applied in the stitching process
* down below
*/
if (typeof batchQuery.limit === 'number') {
tempLimit = batchQuery.limit;
batchQuery.limit = -1;
return nestedCollectionASTs;
}
function mergeWithParentItems(nestedItem: Item | Item[], parentItem: Item | Item[], nestedAST: NestedCollectionAST, o2mLimit?: number | null) {
const nestedItems = Array.isArray(nestedItem) ? nestedItem : [nestedItem];
const parentItems = clone(Array.isArray(parentItem) ? parentItem : [parentItem]);
if (isM2O(nestedAST)) {
for (const parentItem of parentItems) {
const itemChild = nestedItems.find((nestedItem) => {
return nestedItem[nestedAST.relation.one_primary] === parentItem[nestedAST.fieldKey];
});
parentItem[nestedAST.fieldKey] = itemChild || null;
}
} else {
for (const parentItem of parentItems) {
let itemChildren = nestedItems.filter((nestedItem) => {
if (nestedItem === null) return false;
if (Array.isArray(nestedItem[nestedAST.relation.many_field])) return true;
return (
nestedItem[nestedAST.relation.many_field] === parentItem[nestedAST.relation.one_primary] ||
nestedItem[nestedAST.relation.many_field]?.[nestedAST.relation.many_primary] === parentItem[nestedAST.relation.one_primary]
);
});
// We re-apply the requested limit here. This forces the _n_ nested items per parent concept
if (o2mLimit !== null) {
itemChildren = itemChildren.slice(0, o2mLimit);
nestedAST.query.limit = o2mLimit;
}
parentItem[nestedAST.fieldKey] = itemChildren.length > 0 ? itemChildren : null;
}
}
return Array.isArray(parentItem) ? parentItems : parentItems[0];
}
function removeTemporaryFields(rawItem: Item | Item[], ast: AST | NestedCollectionAST): Item | Item[] {
const rawItems: Item[] = Array.isArray(rawItem) ? rawItem : [rawItem];
const items: Item[] = [];
const fields = ast.children.filter((child) => child.type === 'field').map((child) => child.name);
const nestedCollections = ast.children.filter((child) => child.type === 'collection') as NestedCollectionAST[];
for (const rawItem of rawItems) {
if (rawItem === null) return rawItem;
const item = fields.includes('*') ? rawItem : pick(rawItem, fields);
for (const nestedCollection of nestedCollections) {
if (item[nestedCollection.fieldKey] !== null) {
item[nestedCollection.fieldKey] = removeTemporaryFields(rawItem[nestedCollection.fieldKey], nestedCollection);
}
}
const nestedResults = await runAST(batch, { query: batchQuery, knex });
results = results.map((record) => {
if (m2o) {
const nestedResult =
clone(
nestedResults.find((nestedRecord) => {
return (
nestedRecord[batch.relation.one_primary] === record[batch.fieldKey]
);
})
) || null;
if (tempField && nestedResult) {
delete nestedResult[tempField];
}
return {
...record,
[batch.fieldKey]: nestedResult,
};
}
// o2m
let resultsForCurrentRecord = nestedResults
.filter((nestedRecord) => {
return (
nestedRecord[batch.relation.many_field] ===
record[batch.relation.one_primary] ||
// In case of nested object:
nestedRecord[batch.relation.many_field]?.[batch.relation.many_primary] ===
record[batch.relation.one_primary]
);
})
.map((nestedRecord) => {
if (tempField) {
delete nestedRecord[tempField];
}
return nestedRecord;
});
// Reapply LIMIT query on a per-record basis
if (typeof tempLimit === 'number') {
resultsForCurrentRecord = resultsForCurrentRecord.slice(0, tempLimit);
}
const newRecord = {
...record,
[batch.fieldKey]: resultsForCurrentRecord,
};
return newRecord;
});
items.push(item);
}
const nestedCollectionKeys = nestedCollections.map(({ fieldKey }) => fieldKey);
if (toplevelFields.includes('*')) {
return results;
}
return results.map((result) =>
pick(result, uniq([...nestedCollectionKeys, ...toplevelFields]))
);
return Array.isArray(rawItem) ? items : items[0];
}
function isM2O(child: NestedCollectionAST) {
@@ -209,3 +235,7 @@ function isM2O(child: NestedCollectionAST) {
child.relation.one_collection === child.name && child.relation.many_field === child.fieldKey
);
}
function isO2M(child: NestedCollectionAST) {
return isM2O(child) === false;
}

View File

@@ -14,6 +14,7 @@ columns:
type: string
length: 128
nullable: false
unique: true
password:
type: string
length: 255
@@ -58,7 +59,7 @@ columns:
token:
type: string
length: 255
last_login:
last_access:
type: timestamp
last_page:
type: string

View File

@@ -3,6 +3,6 @@ import { EventEmitter2 } from 'eventemitter2';
const emitter = new EventEmitter2({ wildcard: true, verboseMemoryLeak: true, delimiter: '.' });
// No-op function to ensure we never end up with no data
emitter.on('item.*.*.before', (input) => input);
emitter.on('*.*.before', input => input);
export default emitter;

View File

@@ -4,7 +4,7 @@ import { FilterOperator } from '../types';
type FailedValidationExtensions = {
field: string;
type: FilterOperator;
type: FilterOperator | 'required';
valid?: number | string | (number | string)[];
invalid?: number | string | (number | string)[];
substring?: string;
@@ -16,8 +16,6 @@ export class FailedValidationException extends BaseException {
field: error.path[0] as string,
};
console.log(error);
const joiType = error.type;
// eq | in | null | empty
@@ -94,6 +92,11 @@ export class FailedValidationException extends BaseException {
extensions.substring = error.context?.substring;
}
// required
if (joiType.endsWith('required')) {
extensions.type = 'required';
}
super(error.message, 400, 'FAILED_VALIDATION', extensions);
}
}

View File

@@ -1,7 +1,7 @@
import { BaseException } from './base';
export class ItemNotFoundException extends BaseException {
constructor(id: string | number, collection: string) {
constructor(id: string | number | (string | number)[], collection: string) {
super(`Item "${id}" doesn't exist in "${collection}".`, 404, 'ITEM_NOT_FOUND');
}
}

39
api/src/grant.ts Normal file
View File

@@ -0,0 +1,39 @@
/**
* Grant is the oAuth library
*/
import env from './env';
const enabledProviders = (env.OAUTH_PROVIDERS as string)
.split(',')
.map((provider) => provider.trim().toLowerCase());
const config: any = {
defaults: {
origin: env.PUBLIC_URL,
transport: 'session',
prefix: '/auth/oauth',
response: ['tokens', 'profile'],
},
};
for (const [key, value] of Object.entries(env)) {
if (key.startsWith('OAUTH') === false) continue;
const parts = key.split('_');
const provider = parts[1].toLowerCase();
if (enabledProviders.includes(provider) === false) continue;
// OAUTH <PROVIDER> SETTING = VALUE
parts.splice(0, 2);
const configKey = parts.join('_').toLowerCase();
config[provider] = {
...(config[provider] || {}),
[configKey]: value,
};
}
export default config;

View File

@@ -49,13 +49,9 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => {
throw new InvalidCredentialsException();
}
/** @TODO verify user status */
req.accountability.user = payload.id;
req.accountability.role = user.role;
req.accountability.admin = user.admin_access === true || user.admin_access == 1;
return next();
} else {
// Try finding the user with the provided token
const user = await database
@@ -77,13 +73,10 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => {
req.accountability.admin = user.admin_access === true || user.admin_access == 1;
}
/**
* @TODO
* Implement static tokens
*
* @NOTE
* We'll silently ignore wrong tokens. This makes sure we prevent brute-forcing static tokens
*/
if (req.accountability?.user) {
await database('directus_users').update({ last_access: new Date() }).where({ id: req.accountability.user });
}
return next();
});

View File

@@ -12,62 +12,84 @@ const sanitizeQuery: RequestHandler = (req, res, next) => {
req.sanitizedQuery = {};
if (!req.query) return;
const query: Query = {
fields: sanitizeFields(req.query.fields) || ['*'],
};
req.sanitizedQuery = sanitize(
{
fields: req.query.fields || '*',
...req.query
},
req.accountability || null
);
if (req.query.limit !== undefined) {
const limit = sanitizeLimit(req.query.limit);
Object.freeze(req.sanitizedQuery);
return next();
};
function sanitize(rawQuery: Record<string, any>, accountability: Accountability | null) {
const query: Query = {};
if (rawQuery.limit !== undefined) {
const limit = sanitizeLimit(rawQuery.limit);
if (typeof limit === 'number') {
query.limit = limit;
}
}
if (req.query.sort) {
query.sort = sanitizeSort(req.query.sort);
if (rawQuery.fields) {
query.fields = sanitizeFields(rawQuery.fields);
}
if (req.query.filter) {
query.filter = sanitizeFilter(req.query.filter, req.accountability || null);
if (rawQuery.sort) {
query.sort = sanitizeSort(rawQuery.sort);
}
if (req.query.limit == '-1') {
if (rawQuery.filter) {
query.filter = sanitizeFilter(rawQuery.filter, accountability || null);
}
if (rawQuery.limit == '-1') {
delete query.limit;
}
if (req.query.offset) {
query.offset = sanitizeOffset(req.query.offset);
if (rawQuery.offset) {
query.offset = sanitizeOffset(rawQuery.offset);
}
if (req.query.page) {
query.page = sanitizePage(req.query.page);
if (rawQuery.page) {
query.page = sanitizePage(rawQuery.page);
}
if (req.query.single) {
query.single = sanitizeSingle(req.query.single);
if (rawQuery.single) {
query.single = sanitizeSingle(rawQuery.single);
}
if (req.query.meta) {
query.meta = sanitizeMeta(req.query.meta);
if (rawQuery.meta) {
query.meta = sanitizeMeta(rawQuery.meta);
}
if (req.query.search && typeof req.query.search === 'string') {
query.search = req.query.search;
if (rawQuery.search && typeof rawQuery.search === 'string') {
query.search = rawQuery.search;
}
if (
req.query.export &&
typeof req.query.export === 'string' &&
['json', 'csv'].includes(req.query.export)
rawQuery.export &&
typeof rawQuery.export === 'string' &&
['json', 'csv'].includes(rawQuery.export)
) {
query.export = req.query.export as 'json' | 'csv';
query.export = rawQuery.export as 'json' | 'csv';
}
req.sanitizedQuery = query;
Object.freeze(req.sanitizedQuery);
return next();
};
if (rawQuery.deep as Record<string, any>) {
if (!query.deep) query.deep = {};
for (const [field, deepRawQuery] of Object.entries(rawQuery.deep)) {
query.deep[field] = sanitize(deepRawQuery as any, accountability);
}
}
return query;
}
export default sanitizeQuery;

View File

@@ -11,6 +11,7 @@ import {
Item,
PrimaryKey,
} from '../types';
import SchemaInspector from 'knex-schema-inspector';
import Knex from 'knex';
import { ForbiddenException, FailedValidationException } from '../exceptions';
import { uniq, merge } from 'lodash';
@@ -139,12 +140,17 @@ export class AuthorizationService {
const parsedPermissions = parseFilter(permissions.permissions, accountability);
ast.query = {
...ast.query,
filter: {
_and: [ast.query.filter || {}, parsedPermissions],
},
};
if (!ast.query.filter || Object.keys(ast.query.filter).length === 0) {
ast.query.filter = { _and: [] };
} else {
ast.query.filter = { _and: [ast.query.filter] };
}
if (parsedPermissions && Object.keys(parsedPermissions).length > 0) {
ast.query.filter._and.push(parsedPermissions);
}
if (ast.query.filter._and.length === 0) delete ast.query.filter._and;
if (permissions.limit && ast.query.limit && ast.query.limit > permissions.limit) {
throw new ForbiddenException(
@@ -185,29 +191,39 @@ export class AuthorizationService {
collection: string,
payload: Partial<Item>[] | Partial<Item>
): Promise<Partial<Item>[] | Partial<Item>> {
const validationErrors: FailedValidationException[] = [];
let payloads = Array.isArray(payload) ? payload : [payload];
const permission = await this.knex
.select<Permission>('*')
.from('directus_permissions')
.where({ action, collection, role: this.accountability?.role || null })
.first();
let permission: Permission | undefined;
if (!permission) throw new ForbiddenException();
if (this.accountability?.admin === true) {
permission = { id: 0, role: this.accountability?.role, collection, action, permissions: {}, validation: {}, limit: null, fields: '*', presets: {}, }
} else {
permission = await this.knex
.select<Permission>('*')
.from('directus_permissions')
.where({ action, collection, role: this.accountability?.role || null })
.first();
const allowedFields = permission.fields?.split(',') || [];
// Check if you have permission to access the fields you're trying to acces
if (allowedFields.includes('*') === false) {
for (const payload of payloads) {
const keysInData = Object.keys(payload);
const invalidKeys = keysInData.filter(
(fieldKey) => allowedFields.includes(fieldKey) === false
);
if (!permission) throw new ForbiddenException();
if (invalidKeys.length > 0) {
throw new ForbiddenException(
`You're not allowed to ${action} field "${invalidKeys[0]}" in collection "${collection}".`
const allowedFields = permission.fields?.split(',') || [];
if (allowedFields.includes('*') === false) {
for (const payload of payloads) {
const keysInData = Object.keys(payload);
const invalidKeys = keysInData.filter(
(fieldKey) => allowedFields.includes(fieldKey) === false
);
if (invalidKeys.length > 0) {
throw new ForbiddenException(
`You're not allowed to ${action} field "${invalidKeys[0]}" in collection "${collection}".`
);
}
}
}
}
@@ -216,16 +232,37 @@ export class AuthorizationService {
payloads = payloads.map((payload) => merge({}, preset, payload));
const schema = generateJoi(permission.validation);
const schemaInspector = SchemaInspector(this.knex);
const columns = await schemaInspector.columnInfo(collection);
const requiredColumns = columns.filter((column) => column.is_nullable === false && column.has_auto_increment === false && column.default_value === null);
for (const payload of payloads) {
const { error } = schema.validate(payload, { abortEarly: false });
if (requiredColumns.length > 0) {
permission.validation = {
_and: [
permission.validation,
{}
]
}
if (error) {
throw error.details.map((details) => new FailedValidationException(details));
if (action === 'create') {
for (const { name } of requiredColumns) {
permission.validation._and[1][name] = {
_required: true
}
}
} else {
for (const { name } of requiredColumns) {
permission.validation._and[1][name] = {
_nnull: true
}
}
}
}
validationErrors.push(...this.validateJoi(permission.validation, payloads));
if (validationErrors.length > 0) throw validationErrors;
if (Array.isArray(payload)) {
return payloads;
} else {
@@ -233,11 +270,49 @@ export class AuthorizationService {
}
}
validateJoi(validation: Record<string, any>, payloads: Partial<Record<string, any>>[]): FailedValidationException[] {
const errors: FailedValidationException[] = [];
/**
* Note there can only be a single _and / _or per level
*/
if (Object.keys(validation)[0] === '_and') {
const subValidation = Object.values(validation)[0];
const nestedErrors = subValidation.map((subObj: Record<string, any>) => this.validateJoi(subObj, payloads)).flat().filter((err?: FailedValidationException) => err);
errors.push(...nestedErrors);
}
if (Object.keys(validation)[0] === '_or') {
const subValidation = Object.values(validation)[0];
const nestedErrors = subValidation.map((subObj: Record<string, any>) => this.validateJoi(subObj, payloads)).flat();
const allErrored = nestedErrors.every((err?: FailedValidationException) => err);
if (allErrored) {
errors.push(...nestedErrors);
}
}
const schema = generateJoi(validation);
for (const payload of payloads) {
const { error } = schema.validate(payload, { abortEarly: false });
if (error) {
errors.push(...error.details.map((details) => new FailedValidationException(details)));
}
}
return errors;
}
async checkAccess(
action: PermissionsAction,
collection: string,
pk: PrimaryKey | PrimaryKey[]
) {
if (this.accountability?.admin === true) return;
const itemsService = new ItemsService(collection, { accountability: this.accountability });
try {

View File

@@ -60,6 +60,10 @@ export class CollectionsService {
throw new InvalidPayloadException(`The "collection" key is required.`);
}
if (payload.collection.startsWith('directus_')) {
throw new InvalidPayloadException(`Collections can't start with "directus_"`);
}
if (await schemaInspector.hasTable(payload.collection)) {
throw new InvalidPayloadException(
`Collection "${payload.collection}" already exists.`
@@ -128,16 +132,16 @@ export class CollectionsService {
const tablesInDatabase = await schemaInspector.tableInfo();
const tables = tablesInDatabase.filter((table) => collectionKeys.includes(table.name));
const meta: any[] = await collectionItemsService.readByQuery({
const meta = await collectionItemsService.readByQuery({
filter: { collection: { _in: collectionKeys } },
});
}) as Collection['meta'][];
const collections: Collection[] = [];
for (const table of tables) {
const collection: Collection = {
collection: table.name,
meta: meta.find((systemInfo) => systemInfo.collection === table.name) || null,
meta: meta.find((systemInfo) => systemInfo?.collection === table.name) || null,
schema: table,
};
@@ -166,16 +170,16 @@ export class CollectionsService {
}
const tablesToFetchInfoFor = tablesInDatabase.map((table) => table.name);
const meta: any[] = await collectionItemsService.readByQuery({
const meta = await collectionItemsService.readByQuery({
filter: { collection: { _in: tablesToFetchInfoFor } },
});
}) as Collection['meta'][];
const collections: Collection[] = [];
for (const table of tablesInDatabase) {
const collection: Collection = {
collection: table.name,
meta: meta.find((systemInfo) => systemInfo.collection === table.name) || null,
meta: meta.find((systemInfo) => systemInfo?.collection === table.name) || null,
schema: table,
};

View File

@@ -5,36 +5,31 @@ import { ItemsService } from '../services/items';
import { ColumnBuilder } from 'knex';
import getLocalType from '../utils/get-local-type';
import { types } from '../types';
import { ForbiddenException } from '../exceptions';
import { ForbiddenException, InvalidPayloadException } from '../exceptions';
import Knex, { CreateTableBuilder } from 'knex';
import { PayloadService } from '../services/payload';
import getDefaultValue from '../utils/get-default-value';
import cache from '../cache';
import SchemaInspector from 'knex-schema-inspector';
type RawField = Partial<Field> & { field: string; type: typeof types[number] };
/**
* @todo
*
* - Only allow admins to create/update/delete
* - Only return fields you have permission to read (based on permissions)
* - Don't use items service, as this is a different case than regular collections
*/
export class FieldsService {
knex: Knex;
accountability: Accountability | null;
itemsService: ItemsService;
payloadService: PayloadService;
schemaInspector: typeof schemaInspector;
constructor(options?: AbstractServiceOptions) {
this.knex = options?.knex || database;
this.schemaInspector = options?.knex ? SchemaInspector(options.knex) : schemaInspector;
this.accountability = options?.accountability || null;
this.itemsService = new ItemsService('directus_fields', options);
this.payloadService = new PayloadService('directus_fields');
}
async readAll(collection?: string) {
async readAll(collection?: string): Promise<Field[]> {
let fields: FieldMeta[];
const nonAuthorizedItemsService = new ItemsService('directus_fields', { knex: this.knex });
@@ -73,9 +68,8 @@ export class FieldsService {
});
const aliasQuery = this.knex
.select<FieldMeta[]>('*')
.from('directus_fields')
.whereIn('special', ['alias', 'o2m', 'm2m']);
.select<any[]>('*')
.from('directus_fields');
if (collection) {
aliasQuery.andWhere('collection', collection);
@@ -83,6 +77,18 @@ export class FieldsService {
let aliasFields = await aliasQuery;
const aliasTypes = ['alias', 'o2m', 'm2m', 'files', 'files', 'translations'];
aliasFields = aliasFields.filter((field) => {
const specials = (field.special || '').split(',');
for (const type of aliasTypes) {
if (specials.includes(type)) return true;
}
return false;
});
aliasFields = (await this.payloadService.processValues('read', aliasFields)) as FieldMeta[];
const aliasFieldsAsField = aliasFields.map((field) => {
@@ -184,10 +190,12 @@ export class FieldsService {
throw new ForbiddenException('Only admins can perform this action.');
}
/**
* @todo
* Check if table / directus_fields row already exists
*/
// Check if field already exists, either as a column, or as a row in directus_fields
if (await this.schemaInspector.hasColumn(collection, field.field)) {
throw new InvalidPayloadException(`Field "${field.field}" already exists in collection "${collection}"`);
} else if (!!await this.knex.select('id').from('directus_fields').where({ collection, field: field.field }).first()) {
throw new InvalidPayloadException(`Field "${field.field}" already exists in collection "${collection}"`);
}
if (field.schema) {
if (table) {
@@ -216,7 +224,7 @@ export class FieldsService {
async updateField(collection: string, field: RawField) {
if (this.accountability && this.accountability.admin !== true) {
throw new ForbiddenException('Only admins can perform this action.');
throw new ForbiddenException('Only admins can perform this action');
}
if (field.schema) {
@@ -339,6 +347,8 @@ export class FieldsService {
column = table[type](field.field /* precision, scale */);
} else if (field.type === 'csv') {
column = table.string(field.field);
} else if (field.type === 'dateTime') {
column = table.dateTime(field.field, { useTz: false });
} else {
column = table[field.type](field.field);
}
@@ -347,7 +357,7 @@ export class FieldsService {
column.defaultTo(field.schema.default_value);
}
if (field.schema.is_nullable !== undefined && field.schema.is_nullable === false) {
if (field.schema?.is_nullable !== undefined && field.schema.is_nullable === false) {
column.notNullable();
} else {
column.nullable();

View File

@@ -8,6 +8,8 @@ import path from 'path';
import { AbstractServiceOptions, File, PrimaryKey } from '../types';
import { clone } from 'lodash';
import cache from '../cache';
import notFound from '../controllers/not-found';
import { ItemNotFoundException } from '../exceptions';
export class FilesService extends ItemsService {
constructor(options?: AbstractServiceOptions) {
@@ -89,7 +91,13 @@ export class FilesService extends ItemsService {
delete(keys: PrimaryKey[]): Promise<PrimaryKey[]>;
async delete(key: PrimaryKey | PrimaryKey[]): Promise<PrimaryKey | PrimaryKey[]> {
const keys = Array.isArray(key) ? key : [key];
const files = await super.readByKey(keys, { fields: ['id', 'storage'] });
let files = await super.readByKey(keys, { fields: ['id', 'storage'] });
if (!files) {
throw new ItemNotFoundException(key, 'directus_files');
}
files = Array.isArray(files) ? files : [files];
for (const file of files) {
const disk = storage.disk(file.storage);

View File

@@ -28,11 +28,13 @@ export class ItemsService implements AbstractService {
collection: string;
knex: Knex;
accountability: Accountability | null;
eventScope: string;
constructor(collection: string, options?: AbstractServiceOptions) {
this.collection = collection;
this.knex = options?.knex || database;
this.accountability = options?.accountability || null;
this.eventScope = this.collection.startsWith('directus_') ? this.collection.substring(9) : 'item';
return this;
}
@@ -52,24 +54,24 @@ export class ItemsService implements AbstractService {
knex: trx,
});
if (this.collection.startsWith('directus_') === false) {
const customProcessed = await emitter.emitAsync(
`item.create.${this.collection}.before`,
payloads,
{
event: `item.create.${this.collection}.before`,
accountability: this.accountability,
collection: this.collection,
item: null,
action: 'create',
payload: payloads,
}
);
const customProcessed = await emitter.emitAsync(
`${this.eventScope}.create.before`,
payloads,
{
event: `${this.eventScope}.create.before`,
accountability: this.accountability,
collection: this.collection,
item: null,
action: 'create',
payload: payloads,
}
);
if (customProcessed) {
payloads = customProcessed[customProcessed.length - 1];
}
if (this.accountability && this.accountability.admin !== true) {
if (this.accountability) {
const authorizationService = new AuthorizationService({
accountability: this.accountability,
knex: trx,
@@ -169,18 +171,16 @@ export class ItemsService implements AbstractService {
await cache.clear();
}
if (this.collection.startsWith('directus_') === false) {
emitter
.emitAsync(`item.create.${this.collection}`, {
event: `item.create.${this.collection}`,
accountability: this.accountability,
collection: this.collection,
item: primaryKeys,
action: 'create',
payload: payloads,
})
.catch((err) => logger.warn(err));
}
emitter
.emitAsync(`${this.eventScope}.create`, {
event: `${this.eventScope}.create`,
accountability: this.accountability,
collection: this.collection,
item: primaryKeys,
action: 'create',
payload: payloads,
})
.catch((err) => logger.warn(err));
return primaryKeys;
});
@@ -188,10 +188,11 @@ export class ItemsService implements AbstractService {
return Array.isArray(data) ? savedPrimaryKeys : savedPrimaryKeys[0];
}
async readByQuery(query: Query): Promise<Item[]> {
async readByQuery(query: Query): Promise<null | Item | Item[]> {
const authorizationService = new AuthorizationService({
accountability: this.accountability,
});
let ast = await getASTFromQuery(this.collection, query, {
accountability: this.accountability,
knex: this.knex,
@@ -202,22 +203,25 @@ export class ItemsService implements AbstractService {
}
const records = await runAST(ast);
return records;
}
readByKey(keys: PrimaryKey[], query?: Query, action?: PermissionsAction): Promise<Item[]>;
readByKey(key: PrimaryKey, query?: Query, action?: PermissionsAction): Promise<Item>;
readByKey(keys: PrimaryKey[], query?: Query, action?: PermissionsAction): Promise<null | Item[]>;
readByKey(key: PrimaryKey, query?: Query, action?: PermissionsAction): Promise<null | Item>;
async readByKey(
key: PrimaryKey | PrimaryKey[],
query: Query = {},
action: PermissionsAction = 'read'
): Promise<Item | Item[]> {
): Promise<null | Item | Item[]> {
query = clone(query);
const schemaInspector = SchemaInspector(this.knex);
const primaryKeyField = await schemaInspector.primary(this.collection);
const keys = Array.isArray(key) ? key : [key];
if (keys.length === 1) {
query.single = true;
}
const queryWithFilter = {
...query,
filter: {
@@ -242,8 +246,8 @@ export class ItemsService implements AbstractService {
ast = await authorizationService.processAST(ast, action);
}
const records = await runAST(ast, { knex: this.knex });
return Array.isArray(key) ? records : records[0];
const result = await runAST(ast, { knex: this.knex });
return result;
}
update(data: Partial<Item>, keys: PrimaryKey[]): Promise<PrimaryKey[]>;
@@ -263,28 +267,30 @@ export class ItemsService implements AbstractService {
let payload = clone(data);
if (this.collection.startsWith('directus_') === false) {
const customProcessed = await emitter.emitAsync(
`item.update.${this.collection}.before`,
const customProcessed = await emitter.emitAsync(
`${this.eventScope}.update.before`,
payload,
{
event: `${this.eventScope}.update.before`,
accountability: this.accountability,
collection: this.collection,
item: null,
action: 'update',
payload,
{
event: `item.update.${this.collection}.before`,
accountability: this.accountability,
collection: this.collection,
item: null,
action: 'update',
payload,
}
);
}
);
if (customProcessed) {
payload = customProcessed[customProcessed.length - 1];
}
if (this.accountability && this.accountability.admin !== true) {
if (this.accountability) {
const authorizationService = new AuthorizationService({
accountability: this.accountability,
});
await authorizationService.checkAccess('update', this.collection, keys);
payload = await authorizationService.validatePayload(
'update',
this.collection,
@@ -351,7 +357,7 @@ export class ItemsService implements AbstractService {
activity: key,
collection: this.collection,
item: keys[index],
data: JSON.stringify(snapshots[index]),
data: JSON.stringify(snapshots?.[index]),
delta: JSON.stringify(payloadWithoutAliases),
}));
@@ -364,8 +370,8 @@ export class ItemsService implements AbstractService {
}
emitter
.emitAsync(`item.update.${this.collection}`, {
event: `item.update.${this.collection}`,
.emitAsync(`${this.eventScope}.update`, {
event: `${this.eventScope}.update`,
accountability: this.accountability,
collection: this.collection,
item: key,
@@ -419,8 +425,8 @@ export class ItemsService implements AbstractService {
await authorizationService.checkAccess('delete', this.collection, key);
}
await emitter.emitAsync(`item.delete.${this.collection}.before`, {
event: `item.update.${this.collection}`,
await emitter.emitAsync(`${this.eventScope}.delete.before`, {
event: `${this.eventScope}.delete.before`,
accountability: this.accountability,
collection: this.collection,
item: keys,
@@ -450,8 +456,8 @@ export class ItemsService implements AbstractService {
}
emitter
.emitAsync(`item.delete.${this.collection}`, {
event: `item.delete.${this.collection}`,
.emitAsync(`${this.eventScope}.delete`, {
event: `${this.eventScope}.delete`,
accountability: this.accountability,
collection: this.collection,
item: keys,
@@ -466,10 +472,9 @@ export class ItemsService implements AbstractService {
async readSingleton(query: Query) {
query = clone(query);
const schemaInspector = SchemaInspector(this.knex);
query.limit = 1;
query.single = true;
const records = await this.readByQuery(query);
const record = records[0];
const record = await this.readByQuery(query) as Item;
if (!record) {
const columns = await schemaInspector.columnInfo(this.collection);

View File

@@ -40,7 +40,7 @@ export class MetaService {
const dbQuery = database(collection).count('*', { as: 'count' });
if (query.filter) {
applyFilter(dbQuery, query.filter);
applyFilter(dbQuery, query.filter, collection);
}
const records = await dbQuery;

View File

@@ -12,6 +12,9 @@ import { ItemsService } from './items';
import { URL } from 'url';
import Knex from 'knex';
import env from '../env';
import SchemaInspector from 'knex-schema-inspector';
import getLocalType from '../utils/get-local-type';
import { format, formatISO } from 'date-fns';
type Action = 'create' | 'read' | 'update';
@@ -138,7 +141,7 @@ export class PayloadService {
action: Action,
payload: Partial<Item> | Partial<Item>[]
): Promise<Partial<Item> | Partial<Item>[]> {
const processedPayload = (Array.isArray(payload) ? payload : [payload]) as Partial<Item>[];
let processedPayload = (Array.isArray(payload) ? payload : [payload]) as Partial<Item>[];
if (processedPayload.length === 0) return [];
@@ -172,6 +175,10 @@ export class PayloadService {
})
);
if (action === 'read') {
await this.processDates(processedPayload);
}
if (['create', 'update'].includes(action)) {
processedPayload.forEach((record) => {
for (const [key, value] of Object.entries(record)) {
@@ -214,6 +221,51 @@ export class PayloadService {
return value;
}
/**
* Knex returns `datetime` and `date` columns as Date.. This is wrong for date / datetime, as those
* shouldn't return with time / timezone info respectively
*/
async processDates(payloads: Partial<Record<string, any>>[]) {
const schemaInspector = SchemaInspector(this.knex);
const columnsInCollection = await schemaInspector.columnInfo(this.collection);
const columnsWithType = columnsInCollection.map((column) => ({
name: column.name,
type: getLocalType(column.type),
}));
const dateColumns = columnsWithType.filter((column) => ['dateTime', 'date', 'timestamp'].includes(column.type));
if (dateColumns.length === 0) return payloads;
for (const dateColumn of dateColumns) {
for (const payload of payloads) {
const value: Date = payload[dateColumn.name];
if (value) {
if (dateColumn.type === 'timestamp') {
const newValue = formatISO(value);
payload[dateColumn.name] = newValue;
}
if (dateColumn.type === 'dateTime') {
// Strip off the Z at the end of a non-timezone datetime value
const newValue = format(value, "yyyy-MM-dd'T'HH:mm:ss");
payload[dateColumn.name] = newValue;
}
if (dateColumn.type === 'date') {
// Strip off the time / timezone information from a date-only value
const newValue = format(value, 'yyyy-MM-dd');
payload[dateColumn.name] = newValue;
}
}
}
}
return payloads;
}
/**
* Recursively save/update all nested related m2o items
*/
@@ -294,11 +346,20 @@ export class PayloadService {
});
for (const relation of relationsToProcess) {
const relatedRecords: Partial<Item>[] = payload[relation.one_field].map(
(record: Partial<Item>) => ({
...record,
[relation.many_field]: parent || payload[relation.one_primary],
})
const relatedRecords: Partial<Item>[] = payload[relation.one_field]
.map(
(record: string | number | Partial<Item>) => {
if (typeof record === 'string' || typeof record === 'number') {
record = {
[relation.many_primary]: record
};
}
return {
...record,
[relation.many_field]: parent || payload[relation.one_primary],
}
}
);
const itemsService = new ItemsService(relation.many_collection, {

View File

@@ -22,7 +22,7 @@ export class RolesService extends ItemsService {
const permissionsForRole = await permissionsService.readByQuery({
fields: ['id'],
filter: { role: { _in: keys } },
});
}) as { id: number }[];
const permissionIDs = permissionsForRole.map((permission) => permission.id);
await permissionsService.delete(permissionIDs);
@@ -34,7 +34,7 @@ export class RolesService extends ItemsService {
const presetsForRole = await presetsService.readByQuery({
fields: ['id'],
filter: { role: { _in: keys } },
});
}) as { id: string }[];
const presetIDs = presetsForRole.map((preset) => preset.id);
await presetsService.delete(presetIDs);
@@ -46,7 +46,7 @@ export class RolesService extends ItemsService {
const usersInRole = await usersService.readByQuery({
fields: ['id'],
filter: { role: { _in: keys } },
});
}) as { id: string }[];
const userIDs = usersInRole.map((user) => user.id);
await usersService.update({ status: 'suspended', role: null }, userIDs);

View File

@@ -5,6 +5,4 @@ export type Accountability = {
ip?: string;
userAgent?: string;
parent?: number;
};

View File

@@ -41,6 +41,6 @@ export type Field = {
collection: string;
field: string;
type: typeof types[number];
schema: Column;
schema: Column | null;
meta: FieldMeta | null;
};

View File

@@ -11,6 +11,7 @@ export type Query = {
meta?: Meta[];
search?: string;
export?: 'json' | 'csv';
deep?: Record<string, Query>;
};
export type Sort = {

View File

@@ -16,10 +16,10 @@ export interface AbstractService {
create(data: Partial<Item>[]): Promise<PrimaryKey[]>;
create(data: Partial<Item>): Promise<PrimaryKey>;
readByQuery(query: Query): Promise<Item[]>;
readByQuery(query: Query): Promise<null | Item | Item[]>;
readByKey(keys: PrimaryKey[], query: Query, action: PermissionsAction): Promise<Item[]>;
readByKey(key: PrimaryKey, query: Query, action: PermissionsAction): Promise<Item>;
readByKey(keys: PrimaryKey[], query: Query, action: PermissionsAction): Promise<null | Item[]>;
readByKey(key: PrimaryKey, query: Query, action: PermissionsAction): Promise<null | Item>;
update(data: Partial<Item>, keys: PrimaryKey[]): Promise<PrimaryKey[]>;
update(data: Partial<Item>, key: PrimaryKey): Promise<PrimaryKey>;

View File

@@ -1,10 +1,11 @@
import { QueryBuilder } from 'knex';
import { Query, Filter } from '../types';
import { schemaInspector } from '../database';
import database, { schemaInspector } from '../database';
import { nanoid } from 'nanoid';
export default async function applyQuery(collection: string, dbQuery: QueryBuilder, query: Query) {
if (query.filter) {
applyFilter(dbQuery, query.filter);
await applyFilter(dbQuery, query.filter, collection);
}
if (query.sort) {
@@ -45,8 +46,13 @@ export default async function applyQuery(collection: string, dbQuery: QueryBuild
}
}
export function applyFilter(dbQuery: QueryBuilder, filter: Filter) {
for (const [key, value] of Object.entries(filter)) {
export async function applyFilter(dbQuery: QueryBuilder, filter: Filter, collection: string) {
for (let [key, value] of Object.entries(filter)) {
// Nested relational filter
if (key.includes('.')) {
key = await applyJoins(dbQuery, key, collection);
}
if (key.startsWith('_') === false) {
let operator = Object.keys(value)[0];
@@ -139,14 +145,55 @@ export function applyFilter(dbQuery: QueryBuilder, filter: Filter) {
if (key === '_or') {
value.forEach((subFilter: Record<string, any>) => {
dbQuery.orWhere((subQuery) => applyFilter(subQuery, subFilter));
dbQuery.orWhere((subQuery) => applyFilter(subQuery, subFilter, collection));
});
}
if (key === '_and') {
value.forEach((subFilter: Record<string, any>) => {
dbQuery.andWhere((subQuery) => applyFilter(subQuery, subFilter));
dbQuery.andWhere((subQuery) => applyFilter(subQuery, subFilter, collection));
});
}
}
}
async function applyJoins(dbQuery: QueryBuilder, path: string, collection: string) {
const pathParts = path.split('.');
let keyName = '';
await addJoins(pathParts);
return keyName;
async function addJoins(pathParts: string[], parentCollection: string = collection) {
const relation = await database
.select('*')
.from('directus_relations')
.where({ one_collection: parentCollection, one_field: pathParts[0] })
.orWhere({ many_collection: parentCollection, many_field: pathParts[0] })
.first();
if (!relation) return;
const isM2O = relation.many_collection === parentCollection && relation.many_field === pathParts[0];
if (isM2O) {
dbQuery.leftJoin(relation.one_collection, `${parentCollection}.${relation.many_field}`, `${relation.one_collection}.${relation.one_primary}`);
} else {
dbQuery.leftJoin(relation.many_collection, `${relation.one_collection}.${relation.one_primary}`, `${relation.many_collection}.${relation.many_field}`);
}
pathParts.shift();
const parent = isM2O ? relation.one_collection : relation.many_collection;
if (pathParts.length === 1) {
keyName = `${parent}.${pathParts[0]}`;
}
if (pathParts.length) {
await addJoins(pathParts, parent);
}
}
}

View File

@@ -1,5 +1,5 @@
import { Filter } from '../types';
import BaseJoi, { AnySchema } from 'joi';
import BaseJoi, { AlternativesSchema, ObjectSchema, AnySchema } from 'joi';
const Joi: typeof BaseJoi = BaseJoi.extend({
type: 'string',
@@ -52,86 +52,97 @@ const Joi: typeof BaseJoi = BaseJoi.extend({
},
});
export default function generateJoi(filter: Filter | null) {
export default function generateJoi(filter: Filter | null): AnySchema {
filter = filter || {};
const schema: Record<string, AnySchema> = {};
if (Object.keys(filter).length === 0) return Joi.any();
let schema: any;
for (const [key, value] of Object.entries(filter)) {
const isField = key.startsWith('_') === false;
if (key.startsWith('_') === false) {
if (!schema) schema = {};
if (isField) {
const operator = Object.keys(value)[0];
const val = Object.keys(value)[1];
if (operator === '_eq') {
schema[key] = Joi.any().equal(Object.values(value)[0]);
}
if (operator === '_neq') {
schema[key] = Joi.any().not(Object.values(value)[0]);
}
if (operator === '_contains') {
// @ts-ignore
schema[key] = Joi.string().contains(Object.values(value)[0]);
}
if (operator === '_ncontains') {
// @ts-ignore
schema[key] = Joi.string().ncontains(Object.values(value)[0]);
}
if (operator === '_in') {
schema[key] = Joi.any().equal(...(Object.values(value)[0] as (string | number)[]));
}
if (operator === '_nin') {
schema[key] = Joi.any().not(...(Object.values(value)[0] as (string | number)[]));
}
if (operator === '_gt') {
schema[key] = Joi.number().greater(Number(Object.values(value)[0]));
}
if (operator === '_gte') {
schema[key] = Joi.number().min(Number(Object.values(value)[0]));
}
if (operator === '_lt') {
schema[key] = Joi.number().less(Number(Object.values(value)[0]));
}
if (operator === '_lte') {
schema[key] = Joi.number().max(Number(Object.values(value)[0]));
}
if (operator === '_null') {
schema[key] = Joi.any().valid(null);
}
if (operator === '_nnull') {
schema[key] = Joi.any().invalid(null);
}
if (operator === '_empty') {
schema[key] = Joi.any().valid('');
}
if (operator === '_nempty') {
schema[key] = Joi.any().invalid('');
}
if (operator === '_between') {
const values = Object.values(value)[0] as number[];
schema[key] = Joi.number().greater(values[0]).less(values[1]);
}
if (operator === '_nbetween') {
const values = Object.values(value)[0] as number[];
schema[key] = Joi.number().less(values[0]).greater(values[1]);
}
schema[key] = getJoi(operator, val);
}
}
return Joi.object(schema).unknown();
}
function getJoi(operator: string, value: any) {
if (operator === '_eq') {
return Joi.any().equal(Object.values(value)[0]);
}
if (operator === '_neq') {
return Joi.any().not(Object.values(value)[0]);
}
if (operator === '_contains') {
// @ts-ignore
return Joi.string().contains(Object.values(value)[0]);
}
if (operator === '_ncontains') {
// @ts-ignore
return Joi.string().ncontains(Object.values(value)[0]);
}
if (operator === '_in') {
return Joi.any().equal(...(Object.values(value)[0] as (string | number)[]));
}
if (operator === '_nin') {
return Joi.any().not(...(Object.values(value)[0] as (string | number)[]));
}
if (operator === '_gt') {
return Joi.number().greater(Number(Object.values(value)[0]));
}
if (operator === '_gte') {
return Joi.number().min(Number(Object.values(value)[0]));
}
if (operator === '_lt') {
return Joi.number().less(Number(Object.values(value)[0]));
}
if (operator === '_lte') {
return Joi.number().max(Number(Object.values(value)[0]));
}
if (operator === '_null') {
return Joi.any().valid(null);
}
if (operator === '_nnull') {
return Joi.any().invalid(null);
}
if (operator === '_empty') {
return Joi.any().valid('');
}
if (operator === '_nempty') {
return Joi.any().invalid('');
}
if (operator === '_between') {
const values = Object.values(value)[0] as number[];
return Joi.number().greater(values[0]).less(values[1]);
}
if (operator === '_nbetween') {
const values = Object.values(value)[0] as number[];
return Joi.number().less(values[0]).greater(values[1]);
}
if (operator === '_required') {
return Joi.invalid(null).required();
}
}

View File

@@ -14,6 +14,7 @@ import {
import database from '../database';
import { clone } from 'lodash';
import Knex from 'knex';
import SchemaInspector from 'knex-schema-inspector';
type GetASTOptions = {
accountability?: Accountability | null;
@@ -25,14 +26,13 @@ export default async function getASTFromQuery(
collection: string,
query: Query,
options?: GetASTOptions
// accountability?: Accountability | null,
// action?: PermissionsAction
): Promise<AST> {
query = clone(query);
const accountability = options?.accountability;
const action = options?.action || 'read';
const knex = options?.knex || database;
const schemaInspector = SchemaInspector(knex);
/**
* we might not need al this info at all times, but it's easier to fetch it all once, than trying to fetch it for every
@@ -56,11 +56,13 @@ export default async function getASTFromQuery(
};
const fields = query.fields || ['*'];
const deep = query.deep || {};
// Prevent fields from showing up in the query object
// Prevent fields/deep from showing up in the query object in further use
delete query.fields;
delete query.deep;
ast.children = parseFields(collection, fields).filter(filterEmptyChildCollections);
ast.children = (await parseFields(collection, fields, deep)).filter(filterEmptyChildCollections);
return ast;
@@ -120,7 +122,7 @@ export default async function getASTFromQuery(
return fields;
}
function parseFields(parentCollection: string, fields: string[]) {
async function parseFields(parentCollection: string, fields: string[], deep?: Record<string, Query>) {
fields = convertWildcards(parentCollection, fields);
if (!fields) return [];
@@ -157,10 +159,10 @@ export default async function getASTFromQuery(
type: 'collection',
name: relatedCollection,
fieldKey: relationalField,
parentKey: 'id' /** @todo this needs to come from somewhere real */,
parentKey: await schemaInspector.primary(parentCollection),
relation: relation,
query: {} /** @todo inject nested query here: ?deep[foo]=bar */,
children: parseFields(relatedCollection, nestedFields).filter(
query: deep?.[relationalField] || {},
children: (await parseFields(relatedCollection, nestedFields)).filter(
filterEmptyChildCollections
),
};

View File

@@ -1,5 +1,6 @@
import { get } from 'lodash';
import env from '../env';
import { ServiceUnavailableException } from '../exceptions';
// The path in JSON to fetch the email address from the profile.
// Note: a lot of services use `email` as the path. We fall back to that as default, so no need to
@@ -17,10 +18,11 @@ const profileMap: Record<string, string> = {};
export default function getEmailFromProfile(provider: string, profile: Record<string, any>) {
const path =
profileMap[provider] || env[`OAUTH_${provider.toUpperCase()}_PROFILE_EMAIL`] || 'email';
const email = get(profile, path);
if (!email) {
throw new Error("Couldn't extract email address from SSO provider response");
throw new ServiceUnavailableException("Couldn't extract email address from SSO provider response", { service: 'oauth', provider });
}
return email;

View File

@@ -1,40 +0,0 @@
import env from '../env';
/**
* Reads the environment variables to construct the configuration object required by Grant
*/
export default function getGrantConfig() {
const enabledProviders = (env.OAUTH_PROVIDERS as string)
.split(',')
.map((provider) => provider.trim());
const config: any = {
defaults: {
origin: env.PUBLIC_URL,
transport: 'session',
prefix: '/auth/sso',
response: ['tokens', 'profile'],
},
};
for (const [key, value] of Object.entries(env)) {
if (key.startsWith('OAUTH') === false) continue;
const parts = key.split('_');
const provider = parts[1].toLowerCase();
if (enabledProviders.includes(provider) === false) continue;
// OAUTH <PROVIDER> SETTING = VALUE
parts.splice(0, 2);
const configKey = parts.join('_').toLowerCase();
config[provider] = {
...(config[provider] || {}),
[configKey]: value,
};
}
return config;
}

View File

@@ -28,6 +28,7 @@ const localTypeMap: Record<string, { type: typeof types[number]; useTimezone?: b
char: { type: 'string' },
date: { type: 'date' },
datetime: { type: 'dateTime' },
dateTime: { type: 'dateTime' },
timestamp: { type: 'timestamp' },
time: { type: 'time' },
float: { type: 'float' },
@@ -70,7 +71,7 @@ const localTypeMap: Record<string, { type: typeof types[number]; useTimezone?: b
bpchar: { type: 'string' },
timestamptz: { type: 'timestamp' },
'timestamp with time zone': { type: 'timestamp', useTimezone: true },
'timestamp without time zone': { type: 'timestamp' },
'timestamp without time zone': { type: 'dateTime' },
timetz: { type: 'time' },
'time with time zone': { type: 'time', useTimezone: true },
'time without time zone': { type: 'time' },

17
api/src/utils/test.ts Normal file
View File

@@ -0,0 +1,17 @@
import Joi from 'joi';
const schema = Joi.alternatives().try(
Joi.object({
name: Joi.string().required(),
age: Joi.number()
}),
Joi.string(),
).match('all');
const value = {
age: 25
};
const { error } = schema.validate(value);
console.log(JSON.stringify(error, null, 2));

View File

@@ -7,3 +7,7 @@ charset = utf-8
indent_style = tab
indent_size = 4
trim_trailing_whitespace = true
[{package.json,*.yml,*.yaml}]
indent_style = space
indent_size = 2

1747
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@directus/app",
"version": "9.0.0-beta.1",
"version": "9.0.0-beta.2",
"private": false,
"description": "Directus is an Open-Source Headless CMS & API for Managing Custom Databases",
"author": "Rijk van Zanten <rijk@rngr.org>",
@@ -30,6 +30,7 @@
"prepublishOnly": "npm run build"
},
"dependencies": {
"@directus/docs": "^9.0.0-beta.2",
"@directus/format-title": "^3.2.0",
"@popperjs/core": "^2.4.3",
"@sindresorhus/slugify": "^1.0.0",
@@ -53,6 +54,7 @@
"csslint": "^1.0.5",
"date-fns": "^2.14.0",
"diff": "^4.0.2",
"highlight.js": "^10.2.0",
"htmlhint": "^0.14.1",
"joi": "^17.2.1",
"js-yaml": "^3.14.0",
@@ -94,6 +96,7 @@
"@types/base-64": "^0.1.3",
"@types/bytes": "^3.1.0",
"@types/diff": "^4.0.2",
"@types/highlight.js": "^9.12.4",
"@types/jest": "^26.0.5",
"@types/marked": "^1.1.0",
"@types/mime-types": "^2.1.0",
@@ -126,6 +129,7 @@
"lint-staged": "^10.2.11",
"mockdate": "^3.0.2",
"prettier": "^2.0.5",
"raw-loader": "^4.0.1",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-is": "^16.13.1",

View File

@@ -13,6 +13,7 @@ import VDivider from './v-divider';
import VError from './v-error';
import VFancySelect from './v-fancy-select';
import VFieldTemplate from './v-field-template';
import VFieldSelect from './v-field-select';
import VForm from './v-form';
import VHover from './v-hover/';
import VIcon from './v-icon/';
@@ -64,6 +65,7 @@ Vue.component('v-divider', VDivider);
Vue.component('v-error', VError);
Vue.component('v-fancy-select', VFancySelect);
Vue.component('v-field-template', VFieldTemplate);
Vue.component('v-field-select', VFieldSelect);
Vue.component('v-form', VForm);
Vue.component('v-hover', VHover);
Vue.component('v-icon', VIcon);

View File

@@ -75,8 +75,8 @@ export default defineComponent({
<style>
body {
--v-chip-color: var(--white);
--v-chip-background-color: var(--primary);
--v-chip-color: var(--black);
--v-chip-background-color: var(--background-normal-alt);
--v-chip-color-hover: var(--white);
--v-chip-background-color-hover: var(--primary-125);
--v-chip-close-color: var(--danger);
@@ -89,7 +89,7 @@ body {
.v-chip {
display: inline-flex;
align-items: center;
height: 32px;
height: 36px;
padding: 0 8px;
color: var(--v-chip-color);
font-weight: var(--weight-normal);

View File

@@ -0,0 +1,4 @@
import VFieldSelect from './v-field-select.vue';
export default VFieldSelect;
export { VFieldSelect };

View File

@@ -0,0 +1 @@
# Field Select

View File

@@ -0,0 +1,167 @@
<template>
<v-notice v-if="!availableFields || availableFields.length === 0">
{{ $t('no_fields_in_collection', { collection: (collectionInfo && collectionInfo.name) || collection }) }}
</v-notice>
<draggable v-else v-model="selectedFields" draggable=".draggable" :set-data="hideDragImage" class="v-field-select">
<v-chip
v-for="(field, index) in selectedFields"
:key="index"
class="field draggable"
v-tooltip="field.field"
@click="removeField(field.field)"
>
{{ field.name }}
</v-chip>
<template #footer>
<v-menu show-arrow v-model="menuActive" class="add" placement="bottom">
<template #activator="{ toggle }">
<v-button @click="toggle" small>
{{ $t('add_field') }}
<v-icon small name="add" />
</v-button>
</template>
<v-list dense>
<field-list-item
v-for="field in availableFields"
:key="field.field"
:field="field"
:depth="depth"
@add="addField"
/>
</v-list>
</v-menu>
</template>
</draggable>
</template>
<script lang="ts">
import { defineComponent, toRefs, ref, watch, onMounted, onUnmounted, PropType, computed } from '@vue/composition-api';
import FieldListItem from '../v-field-template/field-list-item.vue';
import { useFieldsStore } from '@/stores';
import { Field } from '@/types/';
import Draggable from 'vuedraggable';
import useFieldTree from '@/composables/use-field-tree';
import useCollection from '@/composables/use-collection';
import { FieldTree } from '../v-field-template/types';
import hideDragImage from '@/utils/hide-drag-image';
export default defineComponent({
components: { FieldListItem, Draggable },
props: {
disabled: {
type: Boolean,
default: false,
},
value: {
type: Array as PropType<string[]>,
default: null,
},
collection: {
type: String,
required: true,
},
depth: {
type: Number,
default: 1,
},
},
setup(props, { emit }) {
const fieldsStore = useFieldsStore();
const menuActive = ref(false);
const { collection } = toRefs(props);
const { info, primaryKeyField, fields: fieldsInCollection, sortField } = useCollection(collection);
const { tree } = useFieldTree(collection, true);
const _value = computed({
get() {
return props.value || [];
},
set(newVal: string[]) {
emit('input', newVal);
},
});
const selectedFields = computed({
get() {
return _value.value.map((field) => ({
field,
name: findTree(tree.value, field.split('.'))?.name as string,
}));
},
set(newVal: { field: string; name: string }[]) {
_value.value = newVal.map((field) => field.field);
},
});
const availableFields = computed(() => {
return filterTree(tree.value);
});
return {
menuActive,
addField,
removeField,
availableFields,
selectedFields,
hideDragImage,
tree,
collectionInfo: info,
};
function findTree(tree: FieldTree[] | undefined, fieldSections: string[]): FieldTree | undefined {
if (tree === undefined) return undefined;
const fieldObject = tree.find((f) => f.field === fieldSections[0]);
if (fieldObject === undefined) return undefined;
if (fieldSections.length === 1) return fieldObject;
return findTree(fieldObject.children, fieldSections.slice(1));
}
function filterTree(tree: FieldTree[] | undefined, prefix = '') {
if (tree === undefined) return undefined;
const newTree: FieldTree[] = tree.map((field) => {
return {
name: field.name,
field: field.field,
disabled: _value.value.includes(prefix + field.field),
children: filterTree(field.children, prefix + field.field + '.'),
};
});
return newTree.length === 0 ? undefined : newTree;
}
function removeField(field: string) {
_value.value = _value.value.filter((f) => f !== field);
}
function addField(field: string) {
const newArray = _value.value;
newArray.push(field);
_value.value = [...new Set(newArray)];
}
},
});
</script>
<style lang="scss" scoped>
.v-field-select {
display: flex;
flex-wrap: wrap;
}
.v-chip.field {
margin-right: 5px;
&:hover {
background-color: var(--danger);
border-color: var(--danger);
}
}
</style>

View File

@@ -1,6 +1,7 @@
<template>
<v-list-item
v-if="field.children === undefined"
:disabled="field.disabled"
@click="$emit('add', `${parent ? parent + '.' : ''}${field.field}`)"
>
<v-list-item-content>{{ field.name }}</v-list-item-content>
@@ -12,6 +13,7 @@
:key="childField.field"
:parent="`${parent ? parent + '.' : ''}${field.field}`"
:field="childField"
:depth="depth - 1"
@add="$emit('add', $event)"
/>
</v-list-group>
@@ -32,6 +34,10 @@ export default defineComponent({
type: String,
default: null,
},
depth: {
type: Number,
default: 2,
},
},
});
</script>

View File

@@ -1,7 +1,9 @@
import { Field } from '@/types';
import { TranslateResult } from 'vue-i18n';
export type FieldTree = {
field: string;
name: string | TranslateResult;
disabled?: boolean;
children?: FieldTree[];
};

View File

@@ -22,7 +22,7 @@
</template>
<v-list dense>
<field-list-item @add="addField" v-for="field in tree" :key="field.field" :field="field" />
<field-list-item @add="addField" v-for="field in tree" :key="field.field" :field="field" :depth="depth" />
</v-list>
</v-menu>
</template>
@@ -33,6 +33,7 @@ import FieldListItem from './field-list-item.vue';
import { useFieldsStore } from '@/stores';
import { Field } from '@/types/';
import useFieldTree from '@/composables/use-field-tree';
import { FieldTree } from './types';
export default defineComponent({
components: { FieldListItem },
@@ -49,6 +50,10 @@ export default defineComponent({
type: String,
required: true,
},
depth: {
type: Number,
default: 2,
},
},
setup(props, { emit }) {
const fieldsStore = useFieldsStore();
@@ -149,7 +154,9 @@ export default defineComponent({
function addField(fieldKey: string) {
if (!contentEl.value) return;
const field: Field | null = fieldsStore.getField(props.collection, fieldKey);
const field = findTree(tree.value, fieldKey.split('.'));
if (!field) return;
const button = document.createElement('button');
@@ -157,6 +164,12 @@ export default defineComponent({
button.setAttribute('contenteditable', 'false');
button.innerText = String(field.name);
if (window.getSelection()?.rangeCount == 0) {
const range = document.createRange();
range.selectNodeContents(contentEl.value.children[0]);
window.getSelection()?.addRange(range);
}
const range = window.getSelection()?.getRangeAt(0);
if (!range) return;
range.deleteContents();
@@ -176,6 +189,16 @@ export default defineComponent({
onInput();
}
function findTree(tree: FieldTree[] | undefined, fieldSections: string[]): FieldTree | undefined {
if (tree === undefined) return undefined;
const fieldObject = tree.find((f) => f.field === fieldSections[0]);
if (fieldObject === undefined) return undefined;
if (fieldSections.length === 1) return fieldObject;
return findTree(fieldObject.children, fieldSections.slice(1));
}
function joinElements(first: HTMLElement, second: HTMLElement) {
first.innerText += second.innerText;
second.remove();
@@ -241,7 +264,7 @@ export default defineComponent({
return `<span class="text">${part}</span>`;
}
const fieldKey = part.replaceAll(/({|})/g, '').trim();
const field: Field | null = fieldsStore.getField(props.collection, fieldKey);
const field = findTree(tree.value, fieldKey.split('.'));
if (!field) return '';

View File

@@ -8,7 +8,7 @@
/>
<span @click="toggle">
{{ field.name }}
<v-icon class="required" sup name="star" v-if="field.required" />
<v-icon class="required" sup name="star" v-if="field.schema && field.schema.is_nullable === false" />
<v-icon v-if="!disabled" class="ctx-arrow" :class="{ active }" name="arrow_drop_down" />
</span>
</div>

View File

@@ -164,7 +164,9 @@ body {
}
&.disabled {
--v-list-item-color: var(--foreground-subdued);
--v-list-item-color: var(--foreground-subdued) !important;
cursor: not-allowed;
}
@at-root {

View File

@@ -1,11 +1,11 @@
<template>
<div class="v-pagination">
<v-button v-if="value !== 1" class="previous" :disabled="disabled" secondary icon small @click="toPrev">
<v-button class="previous" :disabled="disabled || value === 1" secondary icon small @click="toPrev">
<v-icon name="chevron_left" />
</v-button>
<v-button
v-if="showFirstLast && value > Math.ceil(totalVisible / 2)"
v-if="showFirstLast && value > Math.ceil(totalVisible / 2) + 1 && length > totalVisible"
class="page"
@click="toPage(1)"
secondary
@@ -15,7 +15,9 @@
1
</v-button>
<span v-if="showFirstLast && value > Math.ceil(totalVisible / 2) + 1" class="gap">...</span>
<span v-if="showFirstLast && value > Math.ceil(totalVisible / 2) + 1 && length > totalVisible + 1" class="gap">
...
</span>
<v-button
v-for="page in visiblePages"
@@ -30,12 +32,15 @@
{{ page }}
</v-button>
<span v-if="showFirstLast && value < length - Math.ceil(totalVisible / 2)" class="gap">
<span
v-if="showFirstLast && value < length - Math.ceil(totalVisible / 2) && length > totalVisible + 1"
class="gap"
>
...
</span>
<v-button
v-if="showFirstLast && value <= length - Math.ceil(totalVisible / 2)"
v-if="showFirstLast && value <= length - Math.ceil(totalVisible / 2) && length > totalVisible"
:class="{ active: value === length }"
class="page"
@click="toPage(length)"
@@ -46,7 +51,7 @@
{{ length }}
</v-button>
<v-button v-if="value !== length" class="next" :disabled="disabled" secondary icon small @click="toNext">
<v-button class="next" :disabled="disabled || value === length" secondary icon small @click="toNext">
<v-icon name="chevron_right" />
</v-button>
</div>
@@ -139,9 +144,9 @@ body {
display: flex;
.gap {
display: none;
margin: 0 4px;
color: var(--foreground-subdued);
display: none;
line-height: 2em;
@include breakpoint(small) {

View File

@@ -29,15 +29,66 @@
<p class="type-label">{{ $t('drag_file_here') }}</p>
<p class="type-text">{{ $t('click_to_browse') }}</p>
<input class="browse" type="file" @input="onBrowseSelect" />
<template v-if="fromUrl !== false || fromLibrary !== false">
<v-menu showArrow placement="bottom-end">
<template #activator="{ toggle }">
<v-icon @click="toggle" class="options" name="more_vert" />
</template>
<v-list>
<v-list-item @click="activeDialog = 'choose'" v-if="fromLibrary">
<v-list-item-icon><v-icon name="folder_open" /></v-list-item-icon>
<v-list-item-content>
{{ $t('choose_from_library') }}
</v-list-item-content>
</v-list-item>
<v-list-item @click="activeDialog = 'url'" v-if="fromUrl">
<v-list-item-icon><v-icon name="link" /></v-list-item-icon>
<v-list-item-content>
{{ $t('import_from_url') }}
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
<modal-browse
collection="directus_files"
:active="activeDialog === 'choose'"
@update:active="activeDialog = null"
@input="setSelection"
/>
<v-dialog :active="activeDialog === 'url'" @toggle="activeDialog = null" :persistent="urlLoading">
<v-card>
<v-card-title>{{ $t('import_from_url') }}</v-card-title>
<v-card-text>
<v-input :placeholder="$t('url')" v-model="url" :disabled="urlLoading" />
</v-card-text>
<v-card-actions>
<v-button :disabled="urlLoading" @click="activeDialog = null" secondary>
{{ $t('cancel') }}
</v-button>
<v-button :loading="urlLoading" @click="importFromURL" :disabled="isValidURL === false">
{{ $t('import') }}
</v-button>
</v-card-actions>
</v-card>
</v-dialog>
</template>
</template>
</div>
</template>
<script lang="ts">
import { defineComponent, ref } from '@vue/composition-api';
import { defineComponent, ref, computed, watch } from '@vue/composition-api';
import uploadFiles from '@/utils/upload-files';
import ModalBrowse from '@/views/private/components/modal-browse';
import api from '@/api';
import useItem from '@/composables/use-item';
export default defineComponent({
components: { ModalBrowse },
props: {
multiple: {
type: Boolean,
@@ -47,10 +98,21 @@ export default defineComponent({
type: Object,
default: () => ({}),
},
fromUrl: {
type: Boolean,
default: false,
},
fromLibrary: {
type: Boolean,
default: false,
},
},
setup(props, { emit }) {
const { uploading, progress, error, upload, onBrowseSelect, done, numberOfFiles } = useUpload();
const { onDragEnter, onDragLeave, onDrop, dragging } = useDragging();
const { url, isValidURL, loading: urlLoading, error: urlError, importFromURL } = useURLImport();
const { setSelection } = useSelection();
const activeDialog = ref<'choose' | 'url' | null>(null);
return {
uploading,
@@ -63,6 +125,12 @@ export default defineComponent({
onBrowseSelect,
done,
numberOfFiles,
activeDialog,
url,
isValidURL,
urlLoading,
importFromURL,
setSelection,
};
function useUpload() {
@@ -146,6 +214,67 @@ export default defineComponent({
}
}
}
function useSelection() {
const collection = ref('directus_files');
const image = ref<string | null>(null);
const { item, error, loading } = useItem(collection, image);
function setSelection(selection: string[]) {
if (selection[0]) {
image.value = selection[0];
} else {
image.value = null;
emit('upload', null);
}
}
watch(
() => item.value,
(id) => {
if (error.value === null && loading.value === false) {
emit('upload', item.value);
}
}
);
return { setSelection };
}
function useURLImport() {
const url = ref('');
const loading = ref(false);
const error = ref(null);
const isValidURL = computed(() => {
try {
new URL(url.value);
return true;
} catch {
return false;
}
});
return { url, loading, error, isValidURL, importFromURL };
async function importFromURL() {
loading.value = true;
try {
const response = await api.post(`/files/import`, {
url: url.value,
});
emit('upload', response.data.data);
activeDialog.value = null;
url.value = '';
} catch (err) {
error.value = err;
} finally {
loading.value = false;
}
}
}
},
});
</script>
@@ -214,4 +343,17 @@ export default defineComponent({
width: calc(100% - 64px);
}
}
.options {
position: absolute;
top: 12px;
right: 12px;
color: var(--foreground-subdued);
cursor: pointer;
transition: color var(--medium) var(--transition);
}
.v-upload:hover .options {
color: var(--primary);
}
</style>

View File

@@ -3,17 +3,22 @@ import { FieldTree } from './types';
import { useFieldsStore, useRelationsStore } from '@/stores/';
import { Field, Relation } from '@/types';
export default function useFieldTree(collection: Ref<string>) {
export default function useFieldTree(collection: Ref<string>, showHidden = false) {
const fieldsStore = useFieldsStore();
const relationsStore = useRelationsStore();
const tree = computed<FieldTree[]>(() => {
return fieldsStore
.getFieldsForCollection(collection.value)
.filter(
(field: Field) =>
field.meta?.hidden === false && (field.meta?.special || []).includes('alias') === false
)
.filter((field: Field) => {
let shown = (field.meta?.special || []).includes('alias') === false;
if (showHidden === false && field.meta?.hidden === true) {
shown = false;
}
return shown;
})
.map((field: Field) => parseField(field, []));
function parseField(field: Field, parents: Field[]) {

View File

@@ -111,12 +111,6 @@ export function useItem(collection: Ref<string>, primaryKey: Ref<string | number
if (isNew.value) {
notify({
title: i18n.tc('item_create_failed', isBatch.value ? 2 : 1),
text: i18n.tc('item_in', isBatch.value ? 2 : 1, {
collection: collection.value,
primaryKey: isBatch.value
? (primaryKey.value as string).split(',').join(', ')
: primaryKey.value,
}),
type: 'error',
});
} else {
@@ -138,9 +132,9 @@ export function useItem(collection: Ref<string>, primaryKey: Ref<string | number
.map((err: APIError) => {
return err.extensions;
});
} else {
throw err;
}
throw err;
} finally {
saving.value = false;
}

View File

@@ -3,11 +3,11 @@
</template>
<script lang="ts">
import { defineComponent, ref, watch, PropType } from '@vue/composition-api';
import { defineComponent, ref, watch, PropType, computed } from '@vue/composition-api';
import localizedFormat from '@/utils/localized-format';
import localizedFormatDistance from '@/utils/localized-format-distance';
import i18n from '@/lang';
import parseISO from 'date-fns/parseISO';
import { parseISO, parse } from 'date-fns';
export default defineComponent({
props: {
@@ -26,20 +26,34 @@ export default defineComponent({
},
},
setup(props) {
const localValue = computed(() => {
if (!props.value) return null;
if (props.type === 'timestamp') {
return parseISO(props.value);
} else if (props.type === 'dateTime') {
return parse(props.value, "yyyy-MM-dd'T'HH:mm:ss", new Date());
} else if (props.type === 'date') {
return parse(props.value, 'yyyy-MM-dd', new Date());
} else if (props.type === 'time') {
return parse(props.value, 'HH:mm:ss', new Date());
}
return null;
});
const displayValue = ref<string | null>(null);
watch(
() => props.value,
localValue,
async (newValue) => {
if (newValue === null) {
displayValue.value = null;
return;
}
const date = parseISO(props.value);
if (props.relative) {
displayValue.value = await localizedFormatDistance(date, new Date(), {
displayValue.value = await localizedFormatDistance(newValue, new Date(), {
addSuffix: true,
});
} else {
@@ -47,7 +61,7 @@ export default defineComponent({
if (props.type === 'date') format = String(i18n.t('date-fns_date'));
if (props.type === 'time') format = String(i18n.t('date-fns_time'));
displayValue.value = await localizedFormat(date, format);
displayValue.value = await localizedFormat(newValue, format);
}
},
{ immediate: true }

View File

@@ -1,6 +1,6 @@
<template>
<span class="user" :class="display">
<user-popover v-if="value" :user="value.id">
<user-popover v-if="value" :user="value.id">
<div class="user" :class="display">
<img
v-if="(display === 'avatar' || display === 'both') && src"
:src="src"
@@ -16,8 +16,8 @@
:class="{ circle }"
/>
<span v-if="display === 'name' || display === 'both'">{{ value.first_name }} {{ value.last_name }}</span>
</user-popover>
</span>
</div>
</user-popover>
</template>
<script lang="ts">

View File

@@ -148,7 +148,7 @@ export default defineComponent({
(htmlColorInput.value?.$el as HTMLElement).getElementsByTagName('input')[0].click();
}
const isValidColor = computed<boolean>(() => rgb.value != null);
const isValidColor = computed<boolean>(() => rgb.value !== null);
const { hsl, rgb, hex } = useColor();
@@ -199,7 +199,8 @@ export default defineComponent({
const newColor = Color(newValue);
if (newColor === null || newColor === _rgb.value) return;
_rgb.value = newColor;
}
},
{ immediate: true }
);
const rgb = computed<number[]>({

View File

@@ -1,54 +0,0 @@
import withPadding from '../../../.storybook/decorators/with-padding';
import { defineComponent, ref, watch } from '@vue/composition-api';
import { withKnobs, select } from '@storybook/addon-knobs';
import readme from './readme.md';
import i18n from '@/lang';
import RawValue from '../../../.storybook/raw-value.vue';
export default {
title: 'Interfaces / DateTime',
decorators: [withPadding, withKnobs],
parameters: {
notes: readme,
},
};
export const basic = () =>
defineComponent({
i18n,
props: {
type: {
default: select('Type', ['datetime', 'date', 'time'], 'datetime'),
},
},
components: { RawValue },
setup(props) {
const value = ref('2020-04-28 14:40:00');
watch(
() => props.type,
(newType: string) => {
if (newType === 'datetime') {
value.value = '2020-04-28 14:40:00';
} else if (newType === 'time') {
value.value = '14:40:00';
} else {
// date
value.value = '2020-04-28';
}
}
);
return { value };
},
template: `
<div style="max-width: 300px;">
<interface-datetime
v-model="value"
:type="type"
/>
<portal-target multiple name="outlet" />
<raw-value>{{ value }}</raw-value>
</div>
`,
});

View File

@@ -17,13 +17,13 @@
<div class="date-selects" v-if="type === 'timestamp' || type === 'dateTime' || type === 'date'">
<div class="month">
<v-select :placeholder="$t('month')" :items="months" v-model="localValue.month" />
<v-select :placeholder="$t('month')" :items="monthItems" v-model="month" />
</div>
<div class="date">
<v-select :placeholder="$t('date')" :items="dates" v-model="localValue.date" />
<v-select :placeholder="$t('date')" :items="dateItems" v-model="date" />
</div>
<div class="year">
<v-select :placeholder="$t('year')" :items="years" v-model="localValue.year" allow-other />
<v-select :placeholder="$t('year')" :items="yearItems" v-model="year" allow-other />
</div>
</div>
@@ -32,19 +32,19 @@
<div
class="time-selects"
v-if="type === 'timestamp' || type === 'dateTime' || type === 'time'"
:class="{ seconds: includeSeconds }"
:class="{ seconds: includeSeconds, 'use-24': use24 }"
>
<div class="hour">
<v-select :items="hours" v-model="localValue.hours" />
<v-select :items="hourItems" v-model="hours" />
</div>
<div class="minutes">
<v-select :items="minutesSeconds" v-model="localValue.minutes" />
<v-select :items="minutesSecondItems" v-model="minutes" />
</div>
<div v-if="includeSeconds" class="seconds">
<v-select :items="minutesSeconds" v-model="localValue.seconds" />
<v-select :items="minutesSecondItems" v-model="seconds" />
</div>
<div class="period">
<v-select :items="['am', 'pm']" v-model="localValue.period" />
<div class="period" v-if="use24 === false">
<v-select :items="['am', 'pm']" v-model="period" />
</div>
</div>
@@ -58,7 +58,7 @@
import { defineComponent, ref, watch, computed, reactive, PropType } from '@vue/composition-api';
import formatLocalized from '@/utils/localized-format';
import { i18n } from '@/lang';
import { formatISO, parseISO } from 'date-fns';
import { formatISO, parseISO, format, parse } from 'date-fns';
type LocalValue = {
month: null | number;
@@ -67,7 +67,6 @@ type LocalValue = {
hours: null | number;
minutes: null | number;
seconds: null | number;
period: 'am' | 'pm';
};
export default defineComponent({
@@ -89,121 +88,209 @@ export default defineComponent({
type: Boolean,
default: false,
},
use24: {
type: Boolean,
default: true,
},
},
setup(props, { emit }) {
const valueAsDate = computed(() => {
if (props.value === null) return null;
return parseISO(props.value);
});
const displayValue = ref<string | null>(null);
syncDisplayValue();
const localValue = reactive({
month: null,
date: null,
year: null,
hours: 9,
minutes: 0,
seconds: 0,
period: 'am',
} as LocalValue);
syncLocalValue();
watch(
() => props.value,
(newValue, oldValue) => {
if (newValue !== oldValue && newValue !== null && newValue.length !== 0) {
syncLocalValue();
syncDisplayValue();
}
}
);
watch(
() => localValue,
(newValue) => {
if (
newValue.year !== null &&
String(newValue.year).length === 4 &&
newValue.month !== null &&
newValue.date !== null &&
newValue.hours !== null &&
newValue.minutes !== null &&
newValue.seconds !== null
) {
const { year, month, date, hours, minutes, seconds, period } = newValue;
const asDate = new Date(year, month, date, period === 'am' ? hours : hours + 12, minutes, seconds);
if(valueAsDate.value?.getTime() != asDate.getTime())
emit('input', formatISO(asDate));
}
},
{
deep: true,
}
);
const { months, dates, years, hours, minutesSeconds } = useOptions();
const { _value, year, month, date, hours, minutes, seconds, period } = useLocalValue();
const { yearItems, monthItems, dateItems, hourItems, minutesSecondItems } = useOptions();
const { displayValue } = useDisplayValue();
return {
displayValue,
months,
dates,
years,
year,
month,
date,
hours,
minutesSeconds,
minutes,
seconds,
period,
setToNow,
localValue,
onAMPMInput,
yearItems,
monthItems,
dateItems,
hourItems,
minutesSecondItems,
displayValue,
};
function useLocalValue() {
const _value = computed({
get() {
if (!props.value) return null;
if (props.type === 'timestamp') {
return parseISO(props.value);
} else if (props.type === 'dateTime') {
return parse(props.value, "yyyy-MM-dd'T'HH:mm:ss", new Date());
} else if (props.type === 'date') {
return parse(props.value, 'yyyy-MM-dd', new Date());
} else if (props.type === 'time') {
return parse(props.value, 'HH:mm:ss', new Date());
}
return null;
},
set(newValue: Date | null) {
if (newValue === null) return emit('input', null);
if (props.type === 'timestamp') {
emit('input', formatISO(newValue));
} else if (props.type === 'dateTime') {
emit('input', format(newValue, "yyyy-MM-dd'T'HH:mm:ss"));
} else if (props.type === 'date') {
emit('input', format(newValue, 'yyyy-MM-dd'));
} else if (props.type === 'time') {
emit('input', format(newValue, 'HH:mm:ss'));
}
},
});
const year = computed({
get() {
if (!_value.value) return null;
return _value.value.getFullYear();
},
set(newYear: number | null) {
const newValue = _value.value ? new Date(_value.value) : new Date();
newValue.setFullYear(newYear || 0);
_value.value = newValue;
},
});
const month = computed({
get() {
if (!_value.value) return null;
return _value.value.getMonth();
},
set(newMonth: number | null) {
const newValue = _value.value ? new Date(_value.value) : new Date();
newValue.setMonth(newMonth || 0);
_value.value = newValue;
},
});
const date = computed({
get() {
if (!_value.value) return null;
return _value.value.getDate();
},
set(newDate: number | null) {
const newValue = _value.value ? new Date(_value.value) : new Date();
newValue.setDate(newDate || 1);
_value.value = newValue;
},
});
const hours = computed({
get() {
if (!_value.value) return null;
const hours = _value.value.getHours();
if (props.use24 === false) {
return hours % 12;
}
return hours;
},
set(newHours: number | null) {
const newValue = _value.value ? new Date(_value.value) : new Date();
newValue.setHours(newHours || 0);
_value.value = newValue;
},
});
const minutes = computed({
get() {
if (!_value.value) return null;
return _value.value.getMinutes();
},
set(newMinutes: number | null) {
const newValue = _value.value ? new Date(_value.value) : new Date();
newValue.setMinutes(newMinutes || 0);
_value.value = newValue;
},
});
const seconds = computed({
get() {
if (!_value.value) return null;
return _value.value.getSeconds();
},
set(newSeconds: number | null) {
const newValue = _value.value ? new Date(_value.value) : new Date();
newValue.setSeconds(newSeconds || 0);
_value.value = newValue;
},
});
const period = computed({
get() {
if (!_value.value) return null;
return _value.value.getHours() >= 12 ? 'pm' : 'am';
},
set(newAMPM: 'am' | 'pm' | null) {
const newValue = _value.value ? new Date(_value.value) : new Date();
const current = newValue.getHours() >= 12 ? 'pm' : 'am';
if (current !== newAMPM) {
if (newAMPM === 'am') {
newValue.setHours(newValue.getHours() - 12);
} else {
newValue.setHours(newValue.getHours() + 12);
}
}
_value.value = newValue;
},
});
return { _value, year, month, date, hours, minutes, seconds, period };
}
function setToNow() {
const date = new Date();
localValue.month = date.getMonth();
localValue.date = date.getDate();
localValue.year = date.getFullYear();
localValue.hours = date.getHours();
localValue.minutes = date.getMinutes();
localValue.seconds = date.getSeconds();
_value.value = new Date();
}
function syncLocalValue() {
if (!valueAsDate.value) return;
localValue.month = valueAsDate.value.getMonth();
localValue.date = valueAsDate.value.getDate();
localValue.year = valueAsDate.value?.getFullYear();
localValue.hours = valueAsDate.value?.getHours() % 12;
localValue.minutes = valueAsDate.value?.getMinutes();
localValue.seconds = valueAsDate.value?.getSeconds();
}
function useDisplayValue() {
const displayValue = ref<string | null>(null);
async function syncDisplayValue() {
if (valueAsDate.value === null) return null;
let format = `${i18n.t('date-fns_date')} ${i18n.t('date-fns_time')}`;
watch(_value, setDisplayValue);
if (props.type === 'date') format = String(i18n.t('date-fns_date'));
if (props.type === 'time') format = String(i18n.t('date-fns_time'));
return { displayValue };
displayValue.value = await formatLocalized(valueAsDate.value as Date, format);
}
async function setDisplayValue() {
if (!props.value || !_value.value) {
displayValue.value = null;
return;
}
function onAMPMInput(newValue: 'PM' | 'AM') {
if (!localValue.hours) return;
let format = `${i18n.t('date-fns_date')} ${i18n.t('date-fns_time')}`;
if (newValue === 'AM') {
localValue.hours = localValue.hours - 12;
} else {
localValue.hours = localValue.hours + 12;
if (props.type === 'date') format = String(i18n.t('date-fns_date'));
if (props.type === 'time') format = String(i18n.t('date-fns_time'));
displayValue.value = await formatLocalized(_value.value, format);
}
}
function useOptions() {
const months = computed(() =>
const yearItems = computed(() => {
const current = _value.value?.getFullYear() || new Date().getFullYear();
const years = [];
for (let i = current - 5; i <= current + 5; i++) {
years.push({
text: String(i),
value: i,
});
}
return years;
});
const monthItems = computed(() =>
[
i18n.t('months.january'),
i18n.t('months.february'),
@@ -223,7 +310,7 @@ export default defineComponent({
}))
);
const dates = computed(() => {
const dateItems = computed(() => {
const dates = [];
for (let i = 1; i <= 31; i++) {
@@ -233,24 +320,15 @@ export default defineComponent({
return dates;
});
const years = computed(() => {
const current = valueAsDate.value?.getFullYear() || new Date().getFullYear();
const years = [];
for (let i = current - 5; i <= current + 5; i++) {
years.push({
text: String(i),
value: i,
});
}
return years;
});
const hours = computed(() => {
const hourItems = computed(() => {
const hours = [];
for (let i = 1; i <= 12; i++) {
const hoursInADay = props.use24 ? 24 : 12;
for (let i = 1; i <= hoursInADay; i++) {
let hour = String(i);
if (hour.length === 1) hour = '0' + hour;
hours.push({
text: hour,
value: i,
@@ -260,7 +338,7 @@ export default defineComponent({
return hours;
});
const minutesSeconds = computed(() => {
const minutesSecondItems = computed(() => {
const values = [];
for (let i = 0; i < 60; i++) {
@@ -275,7 +353,7 @@ export default defineComponent({
return values;
});
return { dates, years, months, hours, minutesSeconds };
return { yearItems, monthItems, dateItems, hourItems, minutesSecondItems };
}
},
});
@@ -300,6 +378,14 @@ export default defineComponent({
&.seconds {
grid-template-columns: repeat(4, 1fr);
}
&.use-24 {
grid-template-columns: repeat(2, 1fr);
&.seconds {
grid-template-columns: repeat(3, 1fr);
}
}
}
.month {

View File

@@ -21,6 +21,18 @@ export default defineInterface(({ i18n }) => ({
default_value: false,
},
},
{
field: 'use24',
name: i18n.t('interfaces.datetime.use_24'),
type: 'boolean',
meta: {
width: 'half',
interface: 'toggle',
},
schema: {
default_value: true,
},
},
],
recommendedDisplays: ['datetime'],
}));

View File

@@ -71,7 +71,7 @@
<v-card>
<v-card-title>{{ $t('upload_from_device') }}</v-card-title>
<v-card-text>
<v-upload @upload="onUpload" />
<v-upload @upload="onUpload" from-url />
</v-card-text>
<v-card-actions>
<v-button @click="activeDialog = null" secondary>{{ $t('cancel') }}</v-button>

View File

@@ -59,7 +59,7 @@
<v-dialog v-model="showUpload">
<v-card>
<v-card-title>{{ $t('upload_file') }}</v-card-title>
<v-card-text><v-upload @upload="onUpload" multiple /></v-card-text>
<v-card-text><v-upload @upload="onUpload" multiple from-url /></v-card-text>
<v-card-actions>
<v-button @click="showUpload = false">{{ $t('done') }}</v-button>
</v-card-actions>

View File

@@ -45,7 +45,7 @@
/>
<file-lightbox v-model="lightboxActive" :id="image.id" />
</div>
<v-upload v-else @upload="setImage" />
<v-upload v-else @upload="setImage" from-library from-url />
</div>
</template>
@@ -56,6 +56,7 @@ import formatFilesize from '@/utils/format-filesize';
import i18n from '@/lang';
import FileLightbox from '@/views/private/components/file-lightbox';
import ImageEditor from '@/views/private/components/image-editor';
import { nanoid } from 'nanoid';
import getRootPath from '@/utils/get-root-path';

View File

@@ -1,5 +1,6 @@
import { defineInterface } from '../define';
import InterfaceManyToMany from './many-to-many.vue';
import Options from './options.vue';
export default defineInterface(({ i18n }) => ({
id: 'many-to-many',
@@ -9,19 +10,6 @@ export default defineInterface(({ i18n }) => ({
component: InterfaceManyToMany,
relationship: 'm2m',
types: ['alias'],
options: [
{
field: 'fields',
type: 'json',
name: i18n.tc('field', 0),
meta: {
interface: 'tags',
width: 'full',
options: {
placeholder: i18n.t('readable_fields_copy'),
},
},
},
],
options: Options,
recommendedDisplays: ['related-values'],
}));

View File

@@ -22,7 +22,7 @@
:interface="header.field.interface"
:interface-options="header.field.interfaceOptions"
:type="header.field.type"
:collection="relatedCollection.collection"
:collection="junctionCollection"
:field="header.field.field"
/>
</template>

View File

@@ -0,0 +1,73 @@
<template>
<v-notice type="warning" v-if="junctionCollection === null">
{{ $t('interfaces.one-to-many.no_collection') }}
</v-notice>
<div v-else class="form-grid">
<div class="field full">
<p class="type-label">{{ $t('select_fields') }}</p>
<v-field-select :collection="junctionCollection" v-model="fields" />
</div>
</div>
</template>
<script lang="ts">
import { Field } from '@/types';
import { defineComponent, PropType, computed } from '@vue/composition-api';
import { useRelationsStore } from '@/stores/';
import { Relation } from '@/types';
export default defineComponent({
props: {
collection: {
type: String,
required: true,
},
fieldData: {
type: Object as PropType<Field>,
default: null,
},
relations: {
type: Array as PropType<Relation[]>,
default: () => [],
},
value: {
type: Object as PropType<any>,
default: null,
},
},
setup(props, { emit }) {
const relationsStore = useRelationsStore();
const fields = computed({
get() {
return props.value?.fields;
},
set(newFields: string) {
emit('input', {
...(props.value || {}),
fields: newFields,
});
},
});
const junctionCollection = computed(() => {
if (!props.fieldData || !props.relations || props.relations.length === 0) return null;
const { field } = props.fieldData;
const junctionRelation = props.relations.find(
(relation) => relation.one_collection === props.collection && relation.one_field === field
);
return junctionRelation?.many_collection || null;
});
return { fields, junctionCollection };
},
});
</script>
<style lang="scss" scoped>
@import '@/styles/mixins/form-grid';
.form-grid {
@include form-grid;
}
</style>

View File

@@ -1,5 +1,6 @@
import { defineInterface } from '../define';
import InterfaceManyToOne from './many-to-one.vue';
import Options from './options.vue';
export default defineInterface(({ i18n }) => ({
id: 'many-to-one',
@@ -9,16 +10,6 @@ export default defineInterface(({ i18n }) => ({
component: InterfaceManyToOne,
types: ['uuid', 'string', 'text', 'integer', 'bigInteger'],
relationship: 'm2o',
options: [
{
field: 'template',
name: i18n.t('interfaces.many-to-one.display_template'),
type: 'string',
meta: {
width: 'half',
interface: 'text-input',
},
},
],
options: Options,
recommendedDisplays: ['related-values'],
}));

View File

@@ -0,0 +1,71 @@
<template>
<v-notice class="full" type="warning" v-if="collection === null">
{{ $t('interfaces.one-to-many.no_collection') }}
</v-notice>
<div v-else class="form-grid">
<div class="field full">
<p class="type-label">{{ $t('interfaces.many-to-one.display_template') }}</p>
<v-field-template :collection="relatedCollection" v-model="template" :depth="2"></v-field-template>
</div>
</div>
</template>
<script lang="ts">
import { Field } from '@/types';
import { defineComponent, PropType, computed } from '@vue/composition-api';
import { useRelationsStore } from '@/stores/';
import { Relation } from '@/types/relations';
export default defineComponent({
props: {
collection: {
type: String,
required: true,
},
fieldData: {
type: Object as PropType<Field>,
default: null,
},
relations: {
type: Array as PropType<Relation[]>,
default: () => [],
},
value: {
type: Object as PropType<any>,
default: null,
},
},
setup(props, { emit }) {
const relationsStore = useRelationsStore();
const template = computed({
get() {
return props.value?.template;
},
set(newTemplate: string) {
emit('input', {
...(props.value || {}),
template: newTemplate,
});
},
});
const relatedCollection = computed(() => {
if (!props.fieldData || !props.relations || props.relations.length === 0) return null;
const { field } = props.fieldData;
const relation = props.relations.find(
(relation) => relation.many_collection === props.collection && relation.many_field === field
);
return relation?.one_collection || null;
});
return { template, relatedCollection };
},
});
</script>
<style lang="scss" scoped>
@import '@/styles/mixins/form-grid';
.form-grid {
@include form-grid;
}
</style>

View File

@@ -1,5 +1,6 @@
import { defineInterface } from '../define';
import InterfaceOneToMany from './one-to-many.vue';
import Options from './options.vue';
export default defineInterface(({ i18n }) => ({
id: 'one-to-many',
@@ -9,19 +10,6 @@ export default defineInterface(({ i18n }) => ({
component: InterfaceOneToMany,
types: ['alias'],
relationship: 'o2m',
options: [
{
field: 'fields',
type: 'json',
name: i18n.tc('field', 0),
meta: {
interface: 'tags',
width: 'full',
options: {
placeholder: i18n.t('interfaces.one-to-many.readable_fields_copy'),
},
},
},
],
options: Options,
recommendedDisplays: ['related-values'],
}));

View File

@@ -0,0 +1,72 @@
<template>
<v-notice type="warning" v-if="relatedCollection === null">
{{ $t('interfaces.one-to-many.no_collection') }}
</v-notice>
<div v-else class="form-grid">
<div class="field full">
<p class="type-label">{{ $t('select_fields') }}</p>
<v-field-select :collection="relatedCollection" v-model="fields" />
</div>
</div>
</template>
<script lang="ts">
import { Field, Relation } from '@/types';
import { defineComponent, PropType, computed } from '@vue/composition-api';
import { useRelationsStore } from '@/stores/';
export default defineComponent({
props: {
collection: {
type: String,
required: true,
},
fieldData: {
type: Object as PropType<Field>,
default: null,
},
relations: {
type: Array as PropType<Relation[]>,
default: () => [],
},
value: {
type: Object as PropType<any>,
default: null,
},
},
setup(props, { emit }) {
const relationsStore = useRelationsStore();
const fields = computed({
get() {
return props.value?.fields;
},
set(newFields: string) {
emit('input', {
...(props.value || {}),
fields: newFields,
});
},
});
const relatedCollection = computed(() => {
if (!props.fieldData || !props.relations || props.relations.length === 0) return null;
const { field } = props.fieldData;
const relatedRelation = props.relations.find(
(relation) => relation.one_collection === props.collection && relation.one_field === field
);
return relatedRelation?.many_collection || null;
});
return { fields, relatedCollection };
},
});
</script>
<style lang="scss" scoped>
@import '@/styles/mixins/form-grid.scss';
.form-grid {
@include form-grid;
}
</style>

View File

@@ -39,6 +39,7 @@ export default defineComponent({
set(newVal: FieldMeta[]) {
const fields = newVal.map((meta) => ({
field: meta.field,
name: meta.field,
meta,
}));

View File

@@ -69,8 +69,7 @@ export default defineComponent({
return { updateValues, onSort, removeItem, addNew, showAddNew, hideDragImage, selection };
function updateValues(index: number, updatedValues: any) {
emit(
'input',
emitValue(
props.value.map((item, i) => {
if (i === index) {
return updatedValues;
@@ -82,18 +81,15 @@ export default defineComponent({
}
function onSort(sortedItems: any[]) {
emit('input', sortedItems);
emitValue(sortedItems);
}
function removeItem(row: any) {
selection.value = [];
if (props.value) {
emit(
'input',
props.value.filter((existingItem) => existingItem !== row)
);
emitValue(props.value.filter((existingItem) => existingItem !== row));
} else {
emit('input', null);
emitValue(null);
}
}
@@ -109,11 +105,19 @@ export default defineComponent({
selection.value = [props.value?.length || 0];
if (props.value !== null) {
emit('input', [...props.value, newDefaults]);
emitValue([...props.value, newDefaults]);
} else {
emit('input', [newDefaults]);
emitValue([newDefaults]);
}
}
function emitValue(value: null | any[]) {
if (value === null || value.length === 0) {
return emit('input', null);
}
return emit('input', value);
}
},
});
</script>

View File

@@ -68,7 +68,13 @@ export default defineComponent({
const fieldsStore = useFieldsStore();
const relationsStore = useRelationsStore();
const { relations, translationsCollection, languagesCollection, languageField, translationsPrimaryKeyField } = useRelation();
const {
relations,
translationsCollection,
languagesCollection,
languageField,
translationsPrimaryKeyField,
} = useRelation();
const {
languages,
@@ -84,7 +90,7 @@ export default defineComponent({
const { info, primaryKeyField } = useCollection(languagesCollection);
const defaultTemplate = info.value?.meta?.display_template;
return defaultTemplate || props.template || `{{ $${primaryKeyField.value.field} }}`;
return props.template || defaultTemplate || `{{ ${primaryKeyField.value.field} }}`;
});
return {
@@ -113,10 +119,12 @@ export default defineComponent({
const translationsRelation = computed(() => {
if (!relations.value || relations.value.length === 0) return null;
return relations.value.find((relation: Relation) => {
return relation.one_collection === props.collection && relation.one_field === props.field;
}) || null;
})
return (
relations.value.find((relation: Relation) => {
return relation.one_collection === props.collection && relation.one_field === props.field;
}) || null
);
});
const translationsCollection = computed(() => {
if (!translationsRelation.value) return null;
@@ -130,9 +138,11 @@ export default defineComponent({
const languagesRelation = computed(() => {
if (!relations.value || relations.value.length === 0) return null;
return relations.value.find((relation: Relation) => {
return relation.one_collection !== props.collection && relation.one_field !== props.field;
}) || null;
return (
relations.value.find((relation: Relation) => {
return relation.one_collection !== props.collection && relation.one_field !== props.field;
}) || null
);
});
const languagesCollection = computed(() => {
@@ -143,9 +153,15 @@ export default defineComponent({
const languageField = computed(() => {
if (!languagesRelation.value) return null;
return languagesRelation.value.many_field;
})
});
return { relations, translationsCollection, languagesCollection, languageField, translationsPrimaryKeyField };
return {
relations,
translationsCollection,
languagesCollection,
languageField,
translationsPrimaryKeyField,
};
}
function useLanguages() {

View File

@@ -8,6 +8,7 @@ export default defineInterface(({ i18n }) => ({
icon: 'person',
component: InterfaceUser,
types: ['uuid'],
relationship: 'm2o',
options: [
{
field: 'selectMode',

View File

@@ -10,6 +10,8 @@
"only_show_the_file_extension": "Only show the file extension",
"textarea": "Textarea",
"add_field": "Add Field",
"role_name": "Role Name",
"db_only_click_to_configure": "Database Only: Click to Configure ",
@@ -79,7 +81,8 @@
"empty": "Value has to be empty",
"nempty": "Value can't be empty",
"null": "Value has to be null",
"nnull": "Value can't be null"
"nnull": "Value can't be null",
"required": "Value is required"
},
"all_access": "All Access",
@@ -191,6 +194,17 @@
"not_available_for_type": "Not Available for this Type",
"create_translations": "Create Translations",
"auto_generate": "Auto-Generate",
"this_will_auto_setup_fields_relations": "This will automatically setup all required fields and relations.",
"click_here": "Click here",
"to_manually_setup_translations": "to manually setup translations.",
"click_to_manage_translated_fields": "There are no translated fields yet. Click here to create them. | There is one translated field. Click here to manage it. | There are {count} translated fields. Click here to manage them.",
"fields_group": "Fields Group",
"configure_m2o": "Configure your Many-to-One Relationship...",
"configure_o2m": "Configure your One-to-Many Relationship...",
"configure_m2m": "Configure your Many-to-Many Relationship...",
@@ -532,6 +546,8 @@
"user_directory": "User Directory",
"help_and_docs": "Help & Docs",
"documentation": "Documentation",
"card_size": "Card Size",
"sort_field": "Sort Field",
@@ -675,8 +691,12 @@
"color": "Color",
"circle": "Circle",
"empty_item": "Empty Item",
"log_in_with": "Log In with {provider}",
"filter": "Filter",
"advanced_filter": "Advanced Filter",
"operators": {
@@ -1036,6 +1056,8 @@
}
},
"no_fields_in_collection": "There are no fields in \"{collection}\" yet",
"do_nothing": "Do Nothing",
"generate_and_save_uuid": "Generate and Save UUID",
"save_current_user_id": "Save Current User ID",
@@ -1052,7 +1074,7 @@
"connection": "Connection",
"contains": "Contains",
"continue": "Continue",
"continue_as": "<b>{name}</b> is already authenticated. If you recognize this account, press continue.",
"continue_as": "<b>{name}</b> is currently authenticated. If you recognize this account, press continue.",
"creating_item": "Creating Item",
"creating_item_page_title": "Creating Item: {collection}",
"creating_role": "Creating Role",

View File

@@ -131,7 +131,8 @@
"one-to-many": {
"one-to-many": "One to Many",
"description": "Select multiple related items",
"readable_fields_copy": "Select the fields that the user can view"
"readable_fields_copy": "Select the fields that the user can view",
"no_collection": "The collection could not be found"
},
"radio-buttons": {
"radio-buttons": "Radio Buttons",

View File

@@ -1,45 +1,47 @@
<template>
<div class="layout-cards" :style="{ '--size': size * 40 + 'px' }" ref="layoutElement">
<portal to="layout-options">
<div class="layout-option">
<div class="option-label">{{ $t('layouts.cards.image_source') }}</div>
<div class="field">
<div class="type-label">{{ $t('layouts.cards.image_source') }}</div>
<v-select v-model="imageSource" show-deselect item-value="field" item-text="name" :items="fileFields" />
</div>
<div class="layout-option">
<div class="option-label">{{ $t('layouts.cards.title') }}</div>
<div class="field">
<div class="type-label">{{ $t('layouts.cards.title') }}</div>
<v-field-template :collection="collection" v-model="title" />
</div>
<div class="layout-option">
<div class="option-label">{{ $t('layouts.cards.subtitle') }}</div>
<div class="field">
<div class="type-label">{{ $t('layouts.cards.subtitle') }}</div>
<v-field-template :collection="collection" v-model="subtitle" />
</div>
<v-detail>
<v-detail class="field">
<template #title>{{ $t('layout_setup') }}</template>
<div class="layout-option">
<div class="option-label">{{ $t('layouts.cards.image_fit') }}</div>
<v-select
v-model="imageFit"
:disabled="imageSource === null"
:items="[
{
text: $t('layouts.cards.crop'),
value: 'crop',
},
{
text: $t('layouts.cards.contain'),
value: 'contain',
},
]"
/>
</div>
<div class="nested-options">
<div class="field">
<div class="type-label">{{ $t('layouts.cards.image_fit') }}</div>
<v-select
v-model="imageFit"
:disabled="imageSource === null"
:items="[
{
text: $t('layouts.cards.crop'),
value: 'crop',
},
{
text: $t('layouts.cards.contain'),
value: 'contain',
},
]"
/>
</div>
<div class="layout-option">
<div class="option-label">{{ $t('fallback_icon') }}</div>
<interface-icon v-model="icon" />
<div class="field">
<div class="type-label">{{ $t('fallback_icon') }}</div>
<interface-icon v-model="icon" />
</div>
</div>
</v-detail>
</portal>
@@ -345,10 +347,12 @@ export default defineComponent({
fields.push('type');
}
const sortField = sort.value.startsWith('-') ? sort.value.substring(1) : sort.value;
if (sort.value) {
const sortField = sort.value.startsWith('-') ? sort.value.substring(1) : sort.value;
if (fields.includes(sortField) === false) {
fields.push(sortField);
if (fields.includes(sortField) === false) {
fields.push(sortField);
}
}
const titleSubtitleFields: string[] = [];
@@ -400,6 +404,7 @@ export default defineComponent({
<style lang="scss" scoped>
@import '@/styles/mixins/breakpoint';
@import '@/styles/mixins/form-grid';
.layout-cards {
padding: var(--content-padding);
@@ -465,4 +470,8 @@ export default defineComponent({
.fade-leave-to {
opacity: 0;
}
.nested-options {
@include form-grid;
}
</style>

View File

@@ -85,13 +85,13 @@ export default defineComponent({
},
setup(props, { emit }) {
const type = computed(() => {
if (props.file === null) return null;
if (!props.file || !props.file.type) return null;
if (props.file.type.startsWith('image')) return null;
return props.file.type.split('/')[1];
});
const imageSource = computed(() => {
if (props.file === null) return null;
if (!props.file || !props.file.type) return null;
if (props.file.type.startsWith('image') === false) return null;
if (props.file.type.includes('svg')) return null;
@@ -105,7 +105,7 @@ export default defineComponent({
});
const svgSource = computed(() => {
if (props.file === null) return null;
if (!props.file || !props.file.type) return null;
if (props.file.type.startsWith('image') === false) return null;
if (props.file.type.includes('svg') === false) return null;
@@ -137,6 +137,7 @@ export default defineComponent({
if (props.selectMode === true) {
toggleSelection();
} else {
// eslint-disable-next-line @typescript-eslint/no-empty-function
router.push(props.to, () => {});
}
}

View File

@@ -1,8 +1,8 @@
<template>
<div class="layout-tabular">
<portal to="layout-options">
<div class="layout-option">
<div class="option-label">{{ $t('layouts.tabular.spacing') }}</div>
<div class="field">
<div class="type-label">{{ $t('layouts.tabular.spacing') }}</div>
<v-select
v-model="tableSpacing"
:items="[
@@ -22,8 +22,8 @@
/>
</div>
<div class="layout-option">
<div class="option-label">{{ $t('layouts.tabular.fields') }}</div>
<div class="field">
<div class="type-label">{{ $t('layouts.tabular.fields') }}</div>
<draggable v-model="activeFields" handle=".drag-handle" :set-data="hideDragImage">
<v-checkbox
v-for="field in activeFields"

View File

@@ -0,0 +1,71 @@
<template>
<v-list-item v-if="section.children === undefined" :to="section.to" :dense="dense">
<v-list-item-icon v-if="section.icon !== undefined"><v-icon :name="section.icon" /></v-list-item-icon>
<v-list-item-content>
<v-list-item-title>{{ section.name }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
<div v-else-if="section.flat === true">
<v-divider></v-divider>
<navigation-list-item
v-for="(childSection, index) in section.children"
:key="index"
:section="childSection"
dense
/>
</div>
<v-list-group v-else>
<template #activator>
<v-list-item-icon v-if="section.icon !== undefined"><v-icon :name="section.icon" /></v-list-item-icon>
<v-list-item-content>
<v-list-item-title>{{ section.name }}</v-list-item-title>
</v-list-item-content>
</template>
<navigation-list-item
v-for="(childSection, index) in section.children"
:key="index"
:section="childSection"
dense
/>
</v-list-group>
</template>
<script lang="ts">
import { defineComponent, PropType } from '@vue/composition-api';
import { Section } from './sections';
export default defineComponent({
name: 'navigation-list-item',
props: {
section: {
type: Object as PropType<Section>,
default: null,
},
dense: {
type: Boolean,
default: false,
},
},
});
</script>
<style lang="scss" scoped>
.version {
.v-icon {
color: var(--foreground-subdued);
transition: color var(--fast) var(--transition);
}
::v-deep .type-text {
color: var(--foreground-subdued);
transition: color var(--fast) var(--transition);
}
&:hover {
.v-icon {
color: var(--foreground-normal);
}
::v-deep .type-text {
color: var(--foreground-normal);
}
}
}
</style>

View File

@@ -0,0 +1,18 @@
<template>
<v-list nav>
<navigation-item v-for="item in sections" :key="item.to" :section="item"></navigation-item>
</v-list>
</template>
<script lang="ts">
import { defineComponent } from '@vue/composition-api';
import NavigationItem from './navigation-item.vue';
import sections from './sections';
export default defineComponent({
components: { NavigationItem },
setup() {
return { sections };
},
});
</script>

View File

@@ -0,0 +1,60 @@
export type Section = {
name: string;
to: string;
description?: string;
icon?: string;
sectionIcon?: string;
children?: Section[];
default?: string;
flat?: boolean;
};
const sections: Section[] = [
{
icon: 'bubble_chart',
name: 'Getting Started',
to: '/docs/getting-started',
children: [
{
name: 'Introduction',
to: '/docs/getting-started/introduction',
},
{
name: 'Installation',
to: '/docs/getting-started/installation',
},
{
name: 'Contributing',
to: '/docs/getting-started/contributing',
},
{
name: 'Troubleshooting',
to: '/docs/getting-started/trouble',
children: [
{
name: 'Technical Support',
to: '/docs/getting-started/trouble/tech-support',
},
{
name: 'Premium Support',
to: '/docs/getting-started/trouble/prem-support',
},
],
},
],
},
{
icon: 'school',
name: 'Concepts',
to: '/docs/concepts',
default: 'readme',
},
{
icon: 'format_list_numbered',
name: 'Guides',
to: '/docs/guides',
default: 'readme',
},
];
export default sections;

View File

@@ -0,0 +1,59 @@
import { defineModule } from '@/modules/define';
import Docs from './routes/docs.vue';
import sections, { Section } from './components/sections';
import { Route } from 'vue-router';
function urlSplitter(url: string) {
if (url.startsWith('/docs')) url = url.replace('/docs', '');
if (url.startsWith('/')) url = url.substr(1);
return url.split('/');
}
function urlToSection(urlSections: string[], sections: Section[]): Section | null {
const section = sections.find((s) => urlSplitter(s.to).pop() === urlSections[0]);
if (section === undefined) {
return null;
}
if (urlSections.length === 1) {
let finalSection = section;
while (finalSection.children !== undefined) {
finalSection = finalSection.children[0];
}
if (section.icon) finalSection.icon = section.icon;
return finalSection;
}
if (section.children === undefined) return null;
const sectionDeep = urlToSection(urlSections.slice(1), section.children);
if (
sectionDeep !== null &&
sectionDeep.icon === undefined &&
sectionDeep.sectionIcon === undefined &&
section.icon !== undefined
)
sectionDeep.sectionIcon = section.icon;
return sectionDeep;
}
function props(route: Route) {
const section = urlToSection(urlSplitter(route.path), sections);
return { section };
}
export default defineModule(({ i18n }) => ({
id: 'docs',
name: i18n.t('documentation'),
icon: 'info',
routes: [
{
path: '/*',
component: Docs,
props: props,
},
],
order: 20,
}));

View File

@@ -0,0 +1,95 @@
<template>
<private-view :title="notFound ? $t('page_not_found') : title">
<template #headline>{{ $t('documentation') }}</template>
<template #title-outer:prepend>
<v-button rounded disabled icon>
<v-icon :name="isAPIReference ? 'code' : section.icon || section.sectionIcon" />
</v-button>
</template>
<template #navigation>
<docs-navigation />
</template>
<div v-if="notFound" class="not-found">
<v-info :title="$t('page_not_found')" icon="not_interested">
{{ $t('page_not_found_body') }}
</v-info>
</div>
<markdown v-else>{{ mdString }}</markdown>
</private-view>
</template>
<script lang="ts">
import { defineComponent, ref, computed, watch, PropType } from '@vue/composition-api';
import DocsNavigation from '../components/navigation.vue';
import { Section } from '../components/sections';
import Markdown from './markdown.vue';
import i18n from '@/lang';
declare module '*.md';
export default defineComponent({
components: { DocsNavigation, Markdown },
props: {
section: {
type: Object as PropType<Section>,
default: null,
},
},
setup(props) {
const mdString = ref<string | null>(null);
const loading = ref(true);
const error = ref(null);
const isAPIReference = computed(() => props.section && props.section.to.startsWith('/docs/api-reference'));
const notFound = computed(() => {
return props.section === null || error.value !== null;
});
const title = computed(() => {
return isAPIReference.value ? i18n.t('api_reference') : props.section.name;
});
watch(() => props.section, loadMD, { immediate: true });
return { isAPIReference, notFound, title, mdString };
async function loadMD() {
loading.value = true;
error.value = null;
if (props.section === null) {
mdString.value = null;
return;
}
const loadString = props.section.to.replace('/docs', '');
try {
const md = await import(`raw-loader!@directus/docs${loadString}.md`);
mdString.value = md.default;
} catch (err) {
mdString.value = null;
error.value = err;
} finally {
loading.value = false;
}
}
},
});
</script>
<style lang="scss" scoped>
.v-info {
padding: 20vh 0;
}
.not-found {
display: flex;
align-items: center;
justify-content: center;
padding: 20vh 0;
}
</style>

View File

@@ -0,0 +1,352 @@
<template>
<div class="docs selectable">
<div class="md" v-html="html" />
</div>
</template>
<script lang="ts">
import { defineComponent, ref, computed, watch, PropType, onMounted, onUpdated } from '@vue/composition-api';
import marked from 'marked';
import highlight from 'highlight.js';
export default defineComponent({
setup(props, { slots }) {
const html = ref('');
onMounted(generateHTML);
onUpdated(generateHTML);
return { html };
function generateHTML() {
if (slots.default === null || !slots.default()?.[0]?.text) {
html.value = '';
return;
}
let htmlString = marked(slots.default()[0].text!, {
highlight: (code) => highlight.highlightAuto(code).value,
});
const hintRegex = /:::(.*?) (.*?)\n((\s|.)*?):::/gm;
htmlString = htmlString.replaceAll(
hintRegex,
(match: string, type: string, title: string, body: string) => {
return `<div class="hint ${type}"><p class="hint-title">${title}</p><p class="hint-body">${body}</p></div>`;
}
);
html.value = htmlString;
}
},
});
</script>
<style lang="scss" scoped>
.error {
padding: 20vh 0;
}
.docs {
padding: var(--content-padding);
padding-bottom: var(--content-padding-bottom);
.md {
max-width: 740px;
::v-deep {
font-weight: 500;
font-size: 14px;
& > *:first-child {
margin-top: 0;
}
& > *:last-child {
margin-bottom: 0;
}
a {
text-decoration: underline;
}
h1,
h2,
h3,
h4,
h5,
h6 {
position: relative;
margin: 20px 0 10px;
padding: 0;
font-weight: 600;
cursor: text;
}
pre {
padding: 6px 10px;
overflow: auto;
font-size: 13px;
line-height: 19px;
background-color: var(--background-page);
border: 1px solid var(--background-normal);
border-radius: var(--border-radius);
}
code,
tt {
margin: 0 2px;
padding: 0 5px;
white-space: nowrap;
background-color: var(--background-page);
border: 1px solid var(--background-normal);
border-radius: var(--border-radius);
}
pre code {
margin: 0;
padding: 0;
white-space: pre;
background: transparent;
border: none;
}
pre code,
pre tt {
background-color: transparent;
border: none;
}
h1 tt,
h1 code {
font-size: inherit;
}
h2 tt,
h2 code {
font-size: inherit;
}
h3 tt,
h3 code {
font-size: inherit;
}
h4 tt,
h4 code {
font-size: inherit;
}
h5 tt,
h5 code {
font-size: inherit;
}
h6 tt,
h6 code {
font-size: inherit;
}
h1 {
font-size: 28px;
}
h2 {
font-size: 24px;
}
h3 {
font-size: 18px;
}
h4 {
font-size: 16px;
}
h5 {
font-size: 14px;
}
h6 {
color: var(--foreground-normal);
font-size: 14px;
}
p,
blockquote,
ul,
ol,
dl,
li,
table,
pre {
margin: 15px 0;
}
& > h2:first-child {
margin-top: 0;
padding-top: 0;
}
& > h1:first-child {
margin-top: 0;
padding-top: 0;
}
& > h3:first-child,
& > h4:first-child,
& > h5:first-child,
& > h6:first-child {
margin-top: 0;
padding-top: 0;
}
a:first-child h1,
a:first-child h2,
a:first-child h3,
a:first-child h4,
a:first-child h5,
a:first-child h6 {
margin-top: 0;
padding-top: 0;
}
h1 p,
h2 p,
h3 p,
h4 p,
h5 p,
h6 p {
margin-top: 0;
}
li p.first {
display: inline-block;
}
ul,
ol {
padding-left: 30px;
li {
margin: 0;
}
}
ul :first-child,
ol :first-child {
margin-top: 0;
}
ul :last-child,
ol :last-child {
margin-bottom: 0;
}
blockquote {
padding: 0 15px;
color: var(--foreground-normal);
border-left: 4px solid var(--background-normal);
}
blockquote > :first-child {
margin-top: 0;
}
blockquote > :last-child {
margin-bottom: 0;
}
table {
padding: 0;
border-collapse: collapse;
border-spacing: 0;
}
table tr {
margin: 0;
padding: 0;
background-color: white;
border-top: 1px solid var(--background-normal);
}
table tr:nth-child(2n) {
background-color: var(--background-page);
}
table tr th {
margin: 0;
padding: 6px 13px;
font-weight: bold;
text-align: left;
border: 1px solid var(--background-normal);
}
table tr td {
margin: 0;
padding: 6px 13px;
text-align: left;
border: 1px solid var(--background-normal);
}
table tr th :first-child,
table tr td :first-child {
margin-top: 0;
}
table tr th :last-child,
table tr td :last-child {
margin-bottom: 0;
}
img {
max-width: 100%;
}
.highlight pre {
padding: 6px 10px;
overflow: auto;
font-size: 13px;
line-height: 19px;
background-color: var(--background-page);
border: 1px solid var(--background-normal);
border-radius: var(--border-radius);
}
hr {
margin: 20px auto;
border: none;
border-top: 1px solid var(--background-normal);
}
b,
strong {
font-weight: 600;
}
.hint {
display: inline-block;
padding: 0 16px;
background-color: var(--background-subdued);
border-left: 8px solid var(--primary);
&-title {
font-weight: bold;
}
&.tip {
border-left: 8px solid var(--success);
}
&.warning {
border-left: 8px solid var(--warning);
}
&.danger {
border-left: 8px solid var(--danger);
}
}
}
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More