Django使用CKEditor的相关经验

最近在使用 Django 进行后台管理系统的开发,其中有需求要使用所见即所得的富文本编辑器来让用户上传图片和文字并排版。这里我们选择了CKEditor,原因很简单,因为这个第三方库提供了很好的 AWS S3 的支持,而我们目前也确实把静态资源例如图片放在 S3 上。

配置 AWS S3 ​

我们一般使用django-storages这个库来集成 AWS S3, 首先需要在设置里面配置 AWS 的 AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY 和 AWS_S3_REGION_NAME。

然后自定义一个 Storage class 方便配置,代码如下:

python
class MaterialStorage(S3Boto3Storage):
    bucket_name = 'material-bucket'
    default_acl = 'private'

默认都是私有桶,避免用户拿到图片的 URL 就可以分享给别人。S3 默认开启 AWS_QUERYSTRING_AUTH, 访问资源的格式类似下面这种带有查询参数:

https://s3.amazonaws.com/photos/puppy.jpg?AWSAccessKeyId=AKIAIOSFODNN7EXAMPLE&Signature=NpgCjnDzrM%2BWFzoENXmpNDUsSn8%3D&Expires=1175139620

默认的查询参数认证有效期是一个小时,也就意味着这个链接最多一个小时有效,而且认证参数最大只能设置一周有效。可以有效避免链接泄漏导致的资源泄漏风险。

配置项目导入 CKEditor ​

按照官方指导可以很顺利的导入 CKEditor:

python
from django.db import models
from ckeditor.fields import RichTextUploadingField

class Post(models.Model):
    content = RichTextUploadingField()

CKEditor 配置 S3 ​

官方教程上说明必须关掉AWS_QUERYSTRING_AUTH才能正常使用。但是关掉之后就意味着任何人只要获得资源的链接就可以访问,这是很危险的。所以我们如何保证开启认证参数的同时又能正常使用编辑器呢?这个才是本文的重点。

在开启认证参数的时候,编辑器保存的是当时上传照片时候的认证参数,也就意味着一个小时之后再访问的时候,就没法正常显示图片。如何保证页面载入编辑器的时候,保证图片正常显示呢?

我的思路是在页面载入的时候读取编辑器里面的内容,采用正则表达式匹配存储在 AWS 上的图片,然后更新图片相关的认证信息。

数据库读入时自动更新认证信息 ​

查看 Django 文档的时候发现,我们可以自定义一个 field,然后使用一个 hook 函数 from_db_value。在数据读入的时候进行相关操作,例如把存入的 JSON 字符串转换成 JSON 对象。通过这个方法,我们可以对数据进行加工,更新图片的认证信息。

python
class CKEditorField(RichTextUploadingField):
    def from_db_value(self, value, expression, connection):
        if value:
            return update_image(value)
        else:
            return value

我们自定义了一个 CKEditorField 字段,从数据库读出的时候进行图片认证信息的更新。update_image 函数的实现如下:

python
def update_image(content):
    storage = MaterialStorage()
    s3_regex = re.compile(r'"(https:\/\/amazonaws.com\S+)"', re.MULTILINE)
    links = s3_regex.findall(content)
    for link in links:
        match = re.search(r'\/upload\S+.(png|jpe?g)', link)
        if match:
            path = match.group()
            if storage.exists(path):
                content = content.replace(link, storage.url(path))
    return content

简单的解释一下代码功能,首先匹配 AWS 链接,然后匹配链接中的图片路径,知道图片路径之后。再使用 storage 对象查找是否存在,如果存在的话,获取这个资源的 URL。这时候新的 URL 就带有新的认证信息。通过这种方式,我们就能保证每次页面显示编辑器的时候,里面的图片都可以正常显示出来。

总结 ​

知道答案之后,当然觉得这么做很简单。但是探索出这个答案却非常花时间,首先要明确解决问题的思路,然后顺着这个思路去想出可能的解决方案。最后,问题迎刃而解。

参考 ​

Authenticating Requests: Using Query Parameters

File storage API

使用Service Worker的 Web Push 功能

现代的 Android 应用和 iOS 应用都有相应的推送系统,可以给用户推送消息。即使用户没有打开这个应用,也同样可以收到消息。当然,网页既然要向手机应用靠拢的话,那推送功能也是必不可少。 今天我们就来介绍一下,网页如何实现类似的推送功能。

基于 Service Worker 的推送功能 ​

Service Worker 提供了两种推送服务,一种是基于 Firebase Cloud Messaging 的推送功能,一种是基于 VAPID 的推送功能。第一种已经过时了,所以本文只介绍第二种。

Create a public/private key pair ​

首先,我们需要创建公钥和私钥,类似与 HTTPS 的 RSA 加密。客户端使用公钥订阅推送,服务端使用私钥来发送通知。创建既可以在浏览器进行,也可以使用 Node 创建。当然,推荐在后端生成更安全。

前端生成代码参考链接:util.ts

后端使用web-push可以更方便的生成公钥和私钥。代码如下:

js
function generateVAPIDKeys() {
  const vapidKeys = webpush.generateVAPIDKeys();

  return {
    publicKey: vapidKeys.publicKey,
    privateKey: vapidKeys.privateKey
  };
}

Subscribing with the public key ​

接下来我们需要订阅推送:

js
navigator.serviceWorker.ready
  .then(registration => {
    return registration.pushManager.getSubscription().then(subscription => {
      if (subscription === null) {
        return registration.pushManager.subscribe({
          userVisibleOnly: true,
          applicationServerKey: this.vapidPublicKey
        });
      } else {
        return subscription;
      }
    });
  })
  .then(subscription => {
    this.subJSON = subscription.toJSON();
  });

我们使用公钥进行订阅,并且把订阅之后的内容打印出来,方便服务器使用。实际工程中需要发送给服务器进行保存。使用 Chrome 浏览器订阅的信息是这样的:

json
{
  "endpoint": "https://fcm.googleapis.com/fcm/send/d3cMmjx0Eg8:APA91bF1i7wgLJRw-VgOh3Evn6RG1xqdOR6Y0CeTUm1xiD36BCHXaDoceVfilDYiifWdI_rWdU8IdJjqSxaCVscRp5zl9lon8u4mf9mha0fmSVKJzUOx5r5Jba2yiNmCFRxxKcTJm51S",
  "expirationTime": null,
  "keys": {
    "p256dh": "BDtGmJB0Bkyum0WJw8NiiCn4U9ckX8UjhzXPUad2HM0yID0ced8zUHKr-Yhf6p2Z7IS0G07dGG7Tnl5jlwQVog4",
    "auth": "25JGSYqTKvj_nAeodHBHSQ"
  }
}

我们可以看到推送的网址是 Chrome 提供的服务,Firefox 浏览器下订阅的信息是这样的:

json
{
  "endpoint": "https://updates.push.services.mozilla.com/wpush/v2/gAAAAABghCrItYXGJw0eCyp0Ae1lTxMXkb6Fxhg8tRck2cMoY4bZjvkV2j5t95FfrPftdieUgeaNthjmb0_XyoIVqWIy7cpy9lMjczHb5TYpC7sKnOw4IekwrtQbmBo6Vn54TZaUSrBIb40PEy2KXF5QlyOj2QxlTz6d6NPB6mMvJxuYNSg-5xs",
  "keys": {
    "auth": "MCeQnXlaz4A-CBuALNPbcQ",
    "p256dh": "BL4am0lzx005spT_UBbMagfWb93Cfgh8XtkCtP7y697dODFnO0wCVVI783BsiHePRTl-mrpoHolJ0gKTYR1T4SQ"
  }
}

从 endpoint 我们可以看出推送服务本身是浏览器提供的,如果无法访问这个网址,自然就无法使用推送服务。

Server push ​

接下来,我们让服务端发送一个消息试试:

js
const webpush = require('web-push');

const vapidPublicKey = 'public key';

const vapidPrivateKey = 'private key';

// PushSubscriptionJSON Info
const pushSubscription = {
  endpoint: '',
  expirationTime: null,
  keys: {
    p256dh: '',
    auth: ''
  }
};

const payload = 'hello world!';

const options = {
  vapidDetails: {
    subject: 'mailto:example@web-push-node.org',
    publicKey: vapidPublicKey,
    privateKey: vapidPrivateKey
  },
  TTL: 60
};
webpush
  .sendNotification(pushSubscription, payload, options)
  .then(function (result) {
    console.log('success!');
    console.log(result);
  })
  .catch(function (err) {
    console.log('fail!');
    console.error(err);
  });

在上述代码中填入相应的认证信息,就可以使用了。浏览器的推送功能并不需要把相应的网站打开,只要浏览器是打开的就可以收到推送。

总结 ​

浏览器的推送功能非常方便,但是首先需要用户授权通知权限才能使用,其次是浏览器提供的推送服务,可能在中国无法正常使用。所以现在这个功能还没有大规模的应用。

参考 ​

Introduction to Push Notifications

Adding Push Notifications to a Web App

Service Worker Push Notifications Demo

Vue代码风格

良好的代码风格更容易维护代码,虽然没有绝对的标准,但保持自己一贯的代码风格可以提高代码质量。接下来就来介绍我开发 Vue 代码的大致风格吧。

DatePicker.vue ​

vue
<template>
  <!-- 组件名字应该大写字母开头,组件最外层节点类名和组件文件名保持一致,PascalCase改成下划线 -->
  <div class="date-picker" v-click-outside="clickClose">
    <!-- 属性定义应该先定义原生属性,再定义Vue属性 -->
    <div
      class="date-picker-rel"
      ref="reference"
      @click="handleClick"
      :class="{ 'o-disabled': disabled }"
    >
      <slot name="picker">
        <VueInput
          v-model="currentDate"
          class="date-picker-input"
          :disabled="disabled"
          :placeholder="placeholder"
          :clearable="clearable"
          @on-clear="clearDate"
          @on-input="input"
          @on-blur="updateDate"
        >
          <template v-slot:suffix>
            <svg-icon
              name="date"
              class="date-picker-icon"
              :class="{ 'o-disabled': disabled, 'o-show': visible }"
            />
          </template>
        </VueInput>
      </slot>
    </div>
    <transition name="fade">
      <div
        class="date-picker-popper"
        :style="styles"
        ref="popper"
        v-show="visible && !disabled"
      >
        <DateTable
          :selectedDate="formatedDate"
          :isRange="isRange"
          :visible="visible"
          @select="selectDate"
        />
      </div>
    </transition>
  </div>
</template>

<script lang="ts">
/**
 * 模块定义:
 * 1. 先第三方后自定义
 * 2. 除非是组件内部私有模块,否则全部使用 @ 绝对路径引用
 * 3. 按需引用
 */
import { Component, Mixins, Prop, Watch } from 'vue-property-decorator';
// @ts-ignore
import { directive as clickOutside } from 'v-click-outside-x';
import Input from '@/components/input';
import PopperMixin from '@/mixins/popper';
import dateUtils from '@/utils/date';
import DateTable from './DateTable.vue';
import { namespace } from 'vuex-class';

const Info = namespace('information');

@Component({
  directives: {
    clickOutside
  },
  components: {
    DateTable,
    VueInput: Input
  },
  provide() {
    return {
      picker: this
    };
  }
})
// 类名和组件名保持一致,方便调试
export default class DatePicker extends Mixins(PopperMixin) {
  /**
   * 属性定义区域
   */
  @Prop() readonly value!: Date | null | Array<Date | null>;
  @Prop({ default: false }) readonly disabled!: boolean;
  @Prop({ default: false, type: Boolean }) readonly clearable!: boolean;
  @Prop({ default: '' }) readonly placeholder!: string;
  /**
   * State映射区域,定义顺序是: State Getter Action Mutation
   */
  @Info.State notifications!: Array<IAnnouncement>;

  /**
   * data定义区域
   */
  currentDate = '';
  updateByInput = false;

  /**
   * computed区域, 如果同时有 get 和 set 记得配对写一起
   */
  get formatedDate(): Array<Date | null> {
    if (Array.isArray(this.value)) {
      return this.value;
    } else {
      return [this.value];
    }
  }

  get isRange() {
    return Array.isArray(this.value);
  }

  /**
   * watch区域, 函数名 on + 属性名 + Changed
   */
  @Watch('value', { immediate: true })
  onValueChanged() {
    this.currentDate = dateUtils.formatDate(this.value);
  }

  /**
   * 生命周期函数
   * beforeCreate -> created -> beforeMount -> mounted -> beforeUpdate ->
   * updated -> activated -> deactivated -> beforeDestroy -> destroyed -> errorCaptured
   */
  created() {
    // noop
  }

  /**
   * methods 区域
   */
  clickClose() {
    if (this.visible && !this.disabled) {
      this.closePopper();
    }
  }

  handleClick() {
    if (!this.disabled) {
      if (this.visible) {
        this.closePopper();
      } else {
        this.showPopper();
      }
    }
  }

  clearDate() {
    if (this.isRange) {
      this.$emit('input', [null, null]);
    } else {
      this.$emit('input', null);
    }
  }

  input() {
    this.updateByInput = true;
  }

  updateDate() {
    if (this.updateByInput) {
      if (this.isRange) {
        const dates = this.currentDate.split(' - ');
        if (dates.length > 1) {
          const [start, end] = dates;
          const startDate = dateUtils.parseDate(start);
          const endDate = dateUtils.parseDate(end);
          if (
            !Number.isNaN(startDate.getTime()) &&
            !Number.isNaN(endDate.getTime())
          ) {
            this.$emit(
              'input',
              [startDate, endDate].sort((s, e) => {
                return s.getTime() - e.getTime();
              })
            );
          } else {
            this.$emit('input', this.value);
            this.currentDate = dateUtils.formatDate(this.value);
          }
        }
      } else {
        const startDate = new Date(this.currentDate);
        if (!Number.isNaN(startDate.getTime())) {
          this.$emit('input', startDate);
        } else {
          this.$emit('input', this.value);
          this.currentDate = dateUtils.formatDate(this.value);
        }
      }
      this.updateByInput = false;
    }
  }

  selectDate(date: Date | Array<Date>) {
    this.currentDate = dateUtils.formatDate(date);
    this.$emit('input', date);
    this.clickClose();
  }
}
</script>
<style lang="scss">
.date-picker {
  display: block;
  /**
   * CSS书写顺序
   * 以 Formatting Model (布局方式,位置) -> Box Model (尺寸) -> Typographic (文本相关) -> Visual (视觉效果)
   * 的顺序书写,可以提高代码的可读性。
   * Formatting Model 相关属性包括: position, top, right, bottom, left, float, display, overflow 等
   * Box Model 相关属性包括: border, margin, padding, width, height 等
   * Typographic 相关属性包括: font, line-height, text-align, word-wrap 等
   * Visual 相关属性包括: background, color, transition, list-style 等
   * 如果有 content 属性, 应该放在最前面。
   */

  &-popper {
    width: 272px;
  }
}
</style>

总结 ​

良好的代码风格可以提高代码的可读性,建议大家都保持一致的风格,方便团队合作。

参考 ​

Organizing your CSS