summaryrefslogtreecommitdiff
path: root/src/server/api/common
diff options
context:
space:
mode:
authorAkihiko Odaki <nekomanma@pixiv.co.jp>2018-03-29 01:20:40 +0900
committerAkihiko Odaki <nekomanma@pixiv.co.jp>2018-03-29 01:54:41 +0900
commit90f8fe7e538bb7e52d2558152a0390e693f39b11 (patch)
tree0f830887053c8f352b1cd0c13ca715fd14c1f030 /src/server/api/common
parentImplement remote account resolution (diff)
downloadsharkey-90f8fe7e538bb7e52d2558152a0390e693f39b11.tar.gz
sharkey-90f8fe7e538bb7e52d2558152a0390e693f39b11.tar.bz2
sharkey-90f8fe7e538bb7e52d2558152a0390e693f39b11.zip
Introduce processor
Diffstat (limited to 'src/server/api/common')
-rw-r--r--src/server/api/common/drive/add-file.ts307
-rw-r--r--src/server/api/common/drive/upload_from_url.ts46
-rw-r--r--src/server/api/common/generate-native-user-token.ts3
-rw-r--r--src/server/api/common/get-friends.ts26
-rw-r--r--src/server/api/common/get-host-lower.ts5
-rw-r--r--src/server/api/common/is-native-token.ts1
-rw-r--r--src/server/api/common/notify.ts50
-rw-r--r--src/server/api/common/push-sw.ts52
-rw-r--r--src/server/api/common/read-messaging-message.ts66
-rw-r--r--src/server/api/common/read-notification.ts52
-rw-r--r--src/server/api/common/signin.ts19
-rw-r--r--src/server/api/common/text/core/syntax-highlighter.ts334
-rw-r--r--src/server/api/common/text/elements/bold.ts14
-rw-r--r--src/server/api/common/text/elements/code.ts17
-rw-r--r--src/server/api/common/text/elements/emoji.ts14
-rw-r--r--src/server/api/common/text/elements/hashtag.ts19
-rw-r--r--src/server/api/common/text/elements/inline-code.ts17
-rw-r--r--src/server/api/common/text/elements/link.ts19
-rw-r--r--src/server/api/common/text/elements/mention.ts17
-rw-r--r--src/server/api/common/text/elements/quote.ts14
-rw-r--r--src/server/api/common/text/elements/url.ts14
-rw-r--r--src/server/api/common/text/index.ts72
-rw-r--r--src/server/api/common/watch-post.ts26
23 files changed, 1204 insertions, 0 deletions
diff --git a/src/server/api/common/drive/add-file.ts b/src/server/api/common/drive/add-file.ts
new file mode 100644
index 0000000000..5f3c69c15a
--- /dev/null
+++ b/src/server/api/common/drive/add-file.ts
@@ -0,0 +1,307 @@
+import { Buffer } from 'buffer';
+import * as fs from 'fs';
+import * as tmp from 'tmp';
+import * as stream from 'stream';
+
+import * as mongodb from 'mongodb';
+import * as crypto from 'crypto';
+import * as _gm from 'gm';
+import * as debug from 'debug';
+import fileType = require('file-type');
+import prominence = require('prominence');
+
+import DriveFile, { getGridFSBucket } from '../../models/drive-file';
+import DriveFolder from '../../models/drive-folder';
+import { pack } from '../../models/drive-file';
+import event, { publishDriveStream } from '../../event';
+import getAcct from '../../../common/user/get-acct';
+import config from '../../../../conf';
+
+const gm = _gm.subClass({
+ imageMagick: true
+});
+
+const log = debug('misskey:drive:add-file');
+
+const tmpFile = (): Promise<string> => new Promise((resolve, reject) => {
+ tmp.file((e, path) => {
+ if (e) return reject(e);
+ resolve(path);
+ });
+});
+
+const addToGridFS = (name: string, readable: stream.Readable, type: string, metadata: any): Promise<any> =>
+ getGridFSBucket()
+ .then(bucket => new Promise((resolve, reject) => {
+ const writeStream = bucket.openUploadStream(name, { contentType: type, metadata });
+ writeStream.once('finish', (doc) => { resolve(doc); });
+ writeStream.on('error', reject);
+ readable.pipe(writeStream);
+ }));
+
+const addFile = async (
+ user: any,
+ path: string,
+ name: string = null,
+ comment: string = null,
+ folderId: mongodb.ObjectID = null,
+ force: boolean = false
+) => {
+ log(`registering ${name} (user: ${getAcct(user)}, path: ${path})`);
+
+ // Calculate hash, get content type and get file size
+ const [hash, [mime, ext], size] = await Promise.all([
+ // hash
+ ((): Promise<string> => new Promise((res, rej) => {
+ const readable = fs.createReadStream(path);
+ const hash = crypto.createHash('md5');
+ const chunks = [];
+ readable
+ .on('error', rej)
+ .pipe(hash)
+ .on('error', rej)
+ .on('data', (chunk) => chunks.push(chunk))
+ .on('end', () => {
+ const buffer = Buffer.concat(chunks);
+ res(buffer.toString('hex'));
+ });
+ }))(),
+ // mime
+ ((): Promise<[string, string | null]> => new Promise((res, rej) => {
+ const readable = fs.createReadStream(path);
+ readable
+ .on('error', rej)
+ .once('data', (buffer: Buffer) => {
+ readable.destroy();
+ const type = fileType(buffer);
+ if (type) {
+ return res([type.mime, type.ext]);
+ } else {
+ // 種類が同定できなかったら application/octet-stream にする
+ return res(['application/octet-stream', null]);
+ }
+ });
+ }))(),
+ // size
+ ((): Promise<number> => new Promise((res, rej) => {
+ fs.stat(path, (err, stats) => {
+ if (err) return rej(err);
+ res(stats.size);
+ });
+ }))()
+ ]);
+
+ log(`hash: ${hash}, mime: ${mime}, ext: ${ext}, size: ${size}`);
+
+ // detect name
+ const detectedName: string = name || (ext ? `untitled.${ext}` : 'untitled');
+
+ if (!force) {
+ // Check if there is a file with the same hash
+ const much = await DriveFile.findOne({
+ md5: hash,
+ 'metadata.user_id': user._id
+ });
+
+ if (much !== null) {
+ log('file with same hash is found');
+ return much;
+ } else {
+ log('file with same hash is not found');
+ }
+ }
+
+ const [wh, averageColor, folder] = await Promise.all([
+ // Width and height (when image)
+ (async () => {
+ // 画像かどうか
+ if (!/^image\/.*$/.test(mime)) {
+ return null;
+ }
+
+ const imageType = mime.split('/')[1];
+
+ // 画像でもPNGかJPEGかGIFでないならスキップ
+ if (imageType != 'png' && imageType != 'jpeg' && imageType != 'gif') {
+ return null;
+ }
+
+ log('calculate image width and height...');
+
+ // Calculate width and height
+ const g = gm(fs.createReadStream(path), name);
+ const size = await prominence(g).size();
+
+ log(`image width and height is calculated: ${size.width}, ${size.height}`);
+
+ return [size.width, size.height];
+ })(),
+ // average color (when image)
+ (async () => {
+ // 画像かどうか
+ if (!/^image\/.*$/.test(mime)) {
+ return null;
+ }
+
+ const imageType = mime.split('/')[1];
+
+ // 画像でもPNGかJPEGでないならスキップ
+ if (imageType != 'png' && imageType != 'jpeg') {
+ return null;
+ }
+
+ log('calculate average color...');
+
+ const buffer = await prominence(gm(fs.createReadStream(path), name)
+ .setFormat('ppm')
+ .resize(1, 1)) // 1pxのサイズに縮小して平均色を取得するというハック
+ .toBuffer();
+
+ const r = buffer.readUInt8(buffer.length - 3);
+ const g = buffer.readUInt8(buffer.length - 2);
+ const b = buffer.readUInt8(buffer.length - 1);
+
+ log(`average color is calculated: ${r}, ${g}, ${b}`);
+
+ return [r, g, b];
+ })(),
+ // folder
+ (async () => {
+ if (!folderId) {
+ return null;
+ }
+ const driveFolder = await DriveFolder.findOne({
+ _id: folderId,
+ user_id: user._id
+ });
+ if (!driveFolder) {
+ throw 'folder-not-found';
+ }
+ return driveFolder;
+ })(),
+ // usage checker
+ (async () => {
+ // Calculate drive usage
+ const usage = await DriveFile
+ .aggregate([{
+ $match: { 'metadata.user_id': user._id }
+ }, {
+ $project: {
+ length: true
+ }
+ }, {
+ $group: {
+ _id: null,
+ usage: { $sum: '$length' }
+ }
+ }])
+ .then((aggregates: any[]) => {
+ if (aggregates.length > 0) {
+ return aggregates[0].usage;
+ }
+ return 0;
+ });
+
+ log(`drive usage is ${usage}`);
+
+ // If usage limit exceeded
+ if (usage + size > user.drive_capacity) {
+ throw 'no-free-space';
+ }
+ })()
+ ]);
+
+ const readable = fs.createReadStream(path);
+
+ const properties = {};
+
+ if (wh) {
+ properties['width'] = wh[0];
+ properties['height'] = wh[1];
+ }
+
+ if (averageColor) {
+ properties['average_color'] = averageColor;
+ }
+
+ return addToGridFS(detectedName, readable, mime, {
+ user_id: user._id,
+ folder_id: folder !== null ? folder._id : null,
+ comment: comment,
+ properties: properties
+ });
+};
+
+/**
+ * Add file to drive
+ *
+ * @param user User who wish to add file
+ * @param file File path or readableStream
+ * @param comment Comment
+ * @param type File type
+ * @param folderId Folder ID
+ * @param force If set to true, forcibly upload the file even if there is a file with the same hash.
+ * @return Object that represents added file
+ */
+export default (user: any, file: string | stream.Readable, ...args) => new Promise<any>((resolve, reject) => {
+ // Get file path
+ new Promise((res: (v: [string, boolean]) => void, rej) => {
+ if (typeof file === 'string') {
+ res([file, false]);
+ return;
+ }
+ if (typeof file === 'object' && typeof file.read === 'function') {
+ tmpFile()
+ .then(path => {
+ const readable: stream.Readable = file;
+ const writable = fs.createWriteStream(path);
+ readable
+ .on('error', rej)
+ .on('end', () => {
+ res([path, true]);
+ })
+ .pipe(writable)
+ .on('error', rej);
+ })
+ .catch(rej);
+ }
+ rej(new Error('un-compatible file.'));
+ })
+ .then(([path, shouldCleanup]): Promise<any> => new Promise((res, rej) => {
+ addFile(user, path, ...args)
+ .then(file => {
+ res(file);
+ if (shouldCleanup) {
+ fs.unlink(path, (e) => {
+ if (e) log(e.stack);
+ });
+ }
+ })
+ .catch(rej);
+ }))
+ .then(file => {
+ log(`drive file has been created ${file._id}`);
+ resolve(file);
+
+ pack(file).then(serializedFile => {
+ // Publish drive_file_created event
+ event(user._id, 'drive_file_created', serializedFile);
+ publishDriveStream(user._id, 'file_created', serializedFile);
+
+ // Register to search database
+ if (config.elasticsearch.enable) {
+ const es = require('../../db/elasticsearch');
+ es.index({
+ index: 'misskey',
+ type: 'drive_file',
+ id: file._id.toString(),
+ body: {
+ name: file.name,
+ user_id: user._id.toString()
+ }
+ });
+ }
+ });
+ })
+ .catch(reject);
+});
diff --git a/src/server/api/common/drive/upload_from_url.ts b/src/server/api/common/drive/upload_from_url.ts
new file mode 100644
index 0000000000..5dd9695936
--- /dev/null
+++ b/src/server/api/common/drive/upload_from_url.ts
@@ -0,0 +1,46 @@
+import * as URL from 'url';
+import { IDriveFile, validateFileName } from '../../models/drive-file';
+import create from './add-file';
+import * as debug from 'debug';
+import * as tmp from 'tmp';
+import * as fs from 'fs';
+import * as request from 'request';
+
+const log = debug('misskey:common:drive:upload_from_url');
+
+export default async (url, user, folderId = null): Promise<IDriveFile> => {
+ let name = URL.parse(url).pathname.split('/').pop();
+ if (!validateFileName(name)) {
+ name = null;
+ }
+
+ // Create temp file
+ const path = await new Promise((res: (string) => void, rej) => {
+ tmp.file((e, path) => {
+ if (e) return rej(e);
+ res(path);
+ });
+ });
+
+ // write content at URL to temp file
+ await new Promise((res, rej) => {
+ const writable = fs.createWriteStream(path);
+ request(url)
+ .on('error', rej)
+ .on('end', () => {
+ writable.close();
+ res(path);
+ })
+ .pipe(writable)
+ .on('error', rej);
+ });
+
+ const driveFile = await create(user, path, name, null, folderId);
+
+ // clean-up
+ fs.unlink(path, (e) => {
+ if (e) log(e.stack);
+ });
+
+ return driveFile;
+};
diff --git a/src/server/api/common/generate-native-user-token.ts b/src/server/api/common/generate-native-user-token.ts
new file mode 100644
index 0000000000..2082b89a5a
--- /dev/null
+++ b/src/server/api/common/generate-native-user-token.ts
@@ -0,0 +1,3 @@
+import rndstr from 'rndstr';
+
+export default () => `!${rndstr('a-zA-Z0-9', 32)}`;
diff --git a/src/server/api/common/get-friends.ts b/src/server/api/common/get-friends.ts
new file mode 100644
index 0000000000..db6313816d
--- /dev/null
+++ b/src/server/api/common/get-friends.ts
@@ -0,0 +1,26 @@
+import * as mongodb from 'mongodb';
+import Following from '../models/following';
+
+export default async (me: mongodb.ObjectID, includeMe: boolean = true) => {
+ // Fetch relation to other users who the I follows
+ // SELECT followee
+ const myfollowing = await Following
+ .find({
+ follower_id: me,
+ // 削除されたドキュメントは除く
+ deleted_at: { $exists: false }
+ }, {
+ fields: {
+ followee_id: true
+ }
+ });
+
+ // ID list of other users who the I follows
+ const myfollowingIds = myfollowing.map(follow => follow.followee_id);
+
+ if (includeMe) {
+ myfollowingIds.push(me);
+ }
+
+ return myfollowingIds;
+};
diff --git a/src/server/api/common/get-host-lower.ts b/src/server/api/common/get-host-lower.ts
new file mode 100644
index 0000000000..fc4b30439e
--- /dev/null
+++ b/src/server/api/common/get-host-lower.ts
@@ -0,0 +1,5 @@
+import { toUnicode } from 'punycode';
+
+export default host => {
+ return toUnicode(host).replace(/[A-Z]+/, match => match.toLowerCase());
+};
diff --git a/src/server/api/common/is-native-token.ts b/src/server/api/common/is-native-token.ts
new file mode 100644
index 0000000000..0769a4812e
--- /dev/null
+++ b/src/server/api/common/is-native-token.ts
@@ -0,0 +1 @@
+export default (token: string) => token[0] == '!';
diff --git a/src/server/api/common/notify.ts b/src/server/api/common/notify.ts
new file mode 100644
index 0000000000..ae5669b84c
--- /dev/null
+++ b/src/server/api/common/notify.ts
@@ -0,0 +1,50 @@
+import * as mongo from 'mongodb';
+import Notification from '../models/notification';
+import Mute from '../models/mute';
+import event from '../event';
+import { pack } from '../models/notification';
+
+export default (
+ notifiee: mongo.ObjectID,
+ notifier: mongo.ObjectID,
+ type: string,
+ content?: any
+) => new Promise<any>(async (resolve, reject) => {
+ if (notifiee.equals(notifier)) {
+ return resolve();
+ }
+
+ // Create notification
+ const notification = await Notification.insert(Object.assign({
+ created_at: new Date(),
+ notifiee_id: notifiee,
+ notifier_id: notifier,
+ type: type,
+ is_read: false
+ }, content));
+
+ resolve(notification);
+
+ // Publish notification event
+ event(notifiee, 'notification',
+ await pack(notification));
+
+ // 3秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する
+ setTimeout(async () => {
+ const fresh = await Notification.findOne({ _id: notification._id }, { is_read: true });
+ if (!fresh.is_read) {
+ //#region ただしミュートしているユーザーからの通知なら無視
+ const mute = await Mute.find({
+ muter_id: notifiee,
+ deleted_at: { $exists: false }
+ });
+ const mutedUserIds = mute.map(m => m.mutee_id.toString());
+ if (mutedUserIds.indexOf(notifier.toString()) != -1) {
+ return;
+ }
+ //#endregion
+
+ event(notifiee, 'unread_notification', await pack(notification));
+ }
+ }, 3000);
+});
diff --git a/src/server/api/common/push-sw.ts b/src/server/api/common/push-sw.ts
new file mode 100644
index 0000000000..b33715eb18
--- /dev/null
+++ b/src/server/api/common/push-sw.ts
@@ -0,0 +1,52 @@
+const push = require('web-push');
+import * as mongo from 'mongodb';
+import Subscription from '../models/sw-subscription';
+import config from '../../../conf';
+
+if (config.sw) {
+ // アプリケーションの連絡先と、サーバーサイドの鍵ペアの情報を登録
+ push.setVapidDetails(
+ config.maintainer.url,
+ config.sw.public_key,
+ config.sw.private_key);
+}
+
+export default async function(userId: mongo.ObjectID | string, type, body?) {
+ if (!config.sw) return;
+
+ if (typeof userId === 'string') {
+ userId = new mongo.ObjectID(userId);
+ }
+
+ // Fetch
+ const subscriptions = await Subscription.find({
+ user_id: userId
+ });
+
+ subscriptions.forEach(subscription => {
+ const pushSubscription = {
+ endpoint: subscription.endpoint,
+ keys: {
+ auth: subscription.auth,
+ p256dh: subscription.publickey
+ }
+ };
+
+ push.sendNotification(pushSubscription, JSON.stringify({
+ type, body
+ })).catch(err => {
+ //console.log(err.statusCode);
+ //console.log(err.headers);
+ //console.log(err.body);
+
+ if (err.statusCode == 410) {
+ Subscription.remove({
+ user_id: userId,
+ endpoint: subscription.endpoint,
+ auth: subscription.auth,
+ publickey: subscription.publickey
+ });
+ }
+ });
+ });
+}
diff --git a/src/server/api/common/read-messaging-message.ts b/src/server/api/common/read-messaging-message.ts
new file mode 100644
index 0000000000..8e5e5b2b68
--- /dev/null
+++ b/src/server/api/common/read-messaging-message.ts
@@ -0,0 +1,66 @@
+import * as mongo from 'mongodb';
+import Message from '../models/messaging-message';
+import { IMessagingMessage as IMessage } from '../models/messaging-message';
+import publishUserStream from '../event';
+import { publishMessagingStream } from '../event';
+import { publishMessagingIndexStream } from '../event';
+
+/**
+ * Mark as read message(s)
+ */
+export default (
+ user: string | mongo.ObjectID,
+ otherparty: string | mongo.ObjectID,
+ message: string | string[] | IMessage | IMessage[] | mongo.ObjectID | mongo.ObjectID[]
+) => new Promise<any>(async (resolve, reject) => {
+
+ const userId = mongo.ObjectID.prototype.isPrototypeOf(user)
+ ? user
+ : new mongo.ObjectID(user);
+
+ const otherpartyId = mongo.ObjectID.prototype.isPrototypeOf(otherparty)
+ ? otherparty
+ : new mongo.ObjectID(otherparty);
+
+ const ids: mongo.ObjectID[] = Array.isArray(message)
+ ? mongo.ObjectID.prototype.isPrototypeOf(message[0])
+ ? (message as mongo.ObjectID[])
+ : typeof message[0] === 'string'
+ ? (message as string[]).map(m => new mongo.ObjectID(m))
+ : (message as IMessage[]).map(m => m._id)
+ : mongo.ObjectID.prototype.isPrototypeOf(message)
+ ? [(message as mongo.ObjectID)]
+ : typeof message === 'string'
+ ? [new mongo.ObjectID(message)]
+ : [(message as IMessage)._id];
+
+ // Update documents
+ await Message.update({
+ _id: { $in: ids },
+ user_id: otherpartyId,
+ recipient_id: userId,
+ is_read: false
+ }, {
+ $set: {
+ is_read: true
+ }
+ }, {
+ multi: true
+ });
+
+ // Publish event
+ publishMessagingStream(otherpartyId, userId, 'read', ids.map(id => id.toString()));
+ publishMessagingIndexStream(userId, 'read', ids.map(id => id.toString()));
+
+ // Calc count of my unread messages
+ const count = await Message
+ .count({
+ recipient_id: userId,
+ is_read: false
+ });
+
+ if (count == 0) {
+ // 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行
+ publishUserStream(userId, 'read_all_messaging_messages');
+ }
+});
diff --git a/src/server/api/common/read-notification.ts b/src/server/api/common/read-notification.ts
new file mode 100644
index 0000000000..3009cc5d08
--- /dev/null
+++ b/src/server/api/common/read-notification.ts
@@ -0,0 +1,52 @@
+import * as mongo from 'mongodb';
+import { default as Notification, INotification } from '../models/notification';
+import publishUserStream from '../event';
+
+/**
+ * Mark as read notification(s)
+ */
+export default (
+ user: string | mongo.ObjectID,
+ message: string | string[] | INotification | INotification[] | mongo.ObjectID | mongo.ObjectID[]
+) => new Promise<any>(async (resolve, reject) => {
+
+ const userId = mongo.ObjectID.prototype.isPrototypeOf(user)
+ ? user
+ : new mongo.ObjectID(user);
+
+ const ids: mongo.ObjectID[] = Array.isArray(message)
+ ? mongo.ObjectID.prototype.isPrototypeOf(message[0])
+ ? (message as mongo.ObjectID[])
+ : typeof message[0] === 'string'
+ ? (message as string[]).map(m => new mongo.ObjectID(m))
+ : (message as INotification[]).map(m => m._id)
+ : mongo.ObjectID.prototype.isPrototypeOf(message)
+ ? [(message as mongo.ObjectID)]
+ : typeof message === 'string'
+ ? [new mongo.ObjectID(message)]
+ : [(message as INotification)._id];
+
+ // Update documents
+ await Notification.update({
+ _id: { $in: ids },
+ is_read: false
+ }, {
+ $set: {
+ is_read: true
+ }
+ }, {
+ multi: true
+ });
+
+ // Calc count of my unread notifications
+ const count = await Notification
+ .count({
+ notifiee_id: userId,
+ is_read: false
+ });
+
+ if (count == 0) {
+ // 全ての(いままで未読だった)通知を(これで)読みましたよというイベントを発行
+ publishUserStream(userId, 'read_all_notifications');
+ }
+});
diff --git a/src/server/api/common/signin.ts b/src/server/api/common/signin.ts
new file mode 100644
index 0000000000..a11ea56c0c
--- /dev/null
+++ b/src/server/api/common/signin.ts
@@ -0,0 +1,19 @@
+import config from '../../../conf';
+
+export default function(res, user, redirect: boolean) {
+ const expires = 1000 * 60 * 60 * 24 * 365; // One Year
+ res.cookie('i', user.account.token, {
+ path: '/',
+ domain: `.${config.hostname}`,
+ secure: config.url.substr(0, 5) === 'https',
+ httpOnly: false,
+ expires: new Date(Date.now() + expires),
+ maxAge: expires
+ });
+
+ if (redirect) {
+ res.redirect(config.url);
+ } else {
+ res.sendStatus(204);
+ }
+}
diff --git a/src/server/api/common/text/core/syntax-highlighter.ts b/src/server/api/common/text/core/syntax-highlighter.ts
new file mode 100644
index 0000000000..c0396b1fc6
--- /dev/null
+++ b/src/server/api/common/text/core/syntax-highlighter.ts
@@ -0,0 +1,334 @@
+function escape(text) {
+ return text
+ .replace(/>/g, '&gt;')
+ .replace(/</g, '&lt;');
+}
+
+// 文字数が多い順にソートします
+// そうしないと、「function」という文字列が与えられたときに「func」が先にマッチしてしまう可能性があるためです
+const _keywords = [
+ 'true',
+ 'false',
+ 'null',
+ 'nil',
+ 'undefined',
+ 'void',
+ 'var',
+ 'const',
+ 'let',
+ 'mut',
+ 'dim',
+ 'if',
+ 'then',
+ 'else',
+ 'switch',
+ 'match',
+ 'case',
+ 'default',
+ 'for',
+ 'each',
+ 'in',
+ 'while',
+ 'loop',
+ 'continue',
+ 'break',
+ 'do',
+ 'goto',
+ 'next',
+ 'end',
+ 'sub',
+ 'throw',
+ 'try',
+ 'catch',
+ 'finally',
+ 'enum',
+ 'delegate',
+ 'function',
+ 'func',
+ 'fun',
+ 'fn',
+ 'return',
+ 'yield',
+ 'async',
+ 'await',
+ 'require',
+ 'include',
+ 'import',
+ 'imports',
+ 'export',
+ 'exports',
+ 'from',
+ 'as',
+ 'using',
+ 'use',
+ 'internal',
+ 'module',
+ 'namespace',
+ 'where',
+ 'select',
+ 'struct',
+ 'union',
+ 'new',
+ 'delete',
+ 'this',
+ 'super',
+ 'base',
+ 'class',
+ 'interface',
+ 'abstract',
+ 'static',
+ 'public',
+ 'private',
+ 'protected',
+ 'virtual',
+ 'partial',
+ 'override',
+ 'extends',
+ 'implements',
+ 'constructor'
+];
+
+const keywords = _keywords
+ .concat(_keywords.map(k => k[0].toUpperCase() + k.substr(1)))
+ .concat(_keywords.map(k => k.toUpperCase()))
+ .sort((a, b) => b.length - a.length);
+
+const symbols = [
+ '=',
+ '+',
+ '-',
+ '*',
+ '/',
+ '%',
+ '~',
+ '^',
+ '&',
+ '|',
+ '>',
+ '<',
+ '!',
+ '?'
+];
+
+const elements = [
+ // comment
+ code => {
+ if (code.substr(0, 2) != '//') return null;
+ const match = code.match(/^\/\/(.+?)(\n|$)/);
+ if (!match) return null;
+ const comment = match[0];
+ return {
+ html: `<span class="comment">${escape(comment)}</span>`,
+ next: comment.length
+ };
+ },
+
+ // block comment
+ code => {
+ const match = code.match(/^\/\*([\s\S]+?)\*\//);
+ if (!match) return null;
+ return {
+ html: `<span class="comment">${escape(match[0])}</span>`,
+ next: match[0].length
+ };
+ },
+
+ // string
+ code => {
+ if (!/^['"`]/.test(code)) return null;
+ const begin = code[0];
+ let str = begin;
+ let thisIsNotAString = false;
+ for (let i = 1; i < code.length; i++) {
+ const char = code[i];
+ if (char == '\\') {
+ str += char;
+ str += code[i + 1] || '';
+ i++;
+ continue;
+ } else if (char == begin) {
+ str += char;
+ break;
+ } else if (char == '\n' || i == (code.length - 1)) {
+ thisIsNotAString = true;
+ break;
+ } else {
+ str += char;
+ }
+ }
+ if (thisIsNotAString) {
+ return null;
+ } else {
+ return {
+ html: `<span class="string">${escape(str)}</span>`,
+ next: str.length
+ };
+ }
+ },
+
+ // regexp
+ code => {
+ if (code[0] != '/') return null;
+ let regexp = '';
+ let thisIsNotARegexp = false;
+ for (let i = 1; i < code.length; i++) {
+ const char = code[i];
+ if (char == '\\') {
+ regexp += char;
+ regexp += code[i + 1] || '';
+ i++;
+ continue;
+ } else if (char == '/') {
+ break;
+ } else if (char == '\n' || i == (code.length - 1)) {
+ thisIsNotARegexp = true;
+ break;
+ } else {
+ regexp += char;
+ }
+ }
+
+ if (thisIsNotARegexp) return null;
+ if (regexp == '') return null;
+ if (regexp[0] == ' ' && regexp[regexp.length - 1] == ' ') return null;
+
+ return {
+ html: `<span class="regexp">/${escape(regexp)}/</span>`,
+ next: regexp.length + 2
+ };
+ },
+
+ // label
+ code => {
+ if (code[0] != '@') return null;
+ const match = code.match(/^@([a-zA-Z_-]+?)\n/);
+ if (!match) return null;
+ const label = match[0];
+ return {
+ html: `<span class="label">${label}</span>`,
+ next: label.length
+ };
+ },
+
+ // number
+ (code, i, source) => {
+ const prev = source[i - 1];
+ if (prev && /[a-zA-Z]/.test(prev)) return null;
+ if (!/^[\-\+]?[0-9\.]+/.test(code)) return null;
+ const match = code.match(/^[\-\+]?[0-9\.]+/)[0];
+ if (match) {
+ return {
+ html: `<span class="number">${match}</span>`,
+ next: match.length
+ };
+ } else {
+ return null;
+ }
+ },
+
+ // nan
+ (code, i, source) => {
+ const prev = source[i - 1];
+ if (prev && /[a-zA-Z]/.test(prev)) return null;
+ if (code.substr(0, 3) == 'NaN') {
+ return {
+ html: `<span class="nan">NaN</span>`,
+ next: 3
+ };
+ } else {
+ return null;
+ }
+ },
+
+ // method
+ code => {
+ const match = code.match(/^([a-zA-Z_-]+?)\(/);
+ if (!match) return null;
+
+ if (match[1] == '-') return null;
+
+ return {
+ html: `<span class="method">${match[1]}</span>`,
+ next: match[1].length
+ };
+ },
+
+ // property
+ (code, i, source) => {
+ const prev = source[i - 1];
+ if (prev != '.') return null;
+
+ const match = code.match(/^[a-zA-Z0-9_-]+/);
+ if (!match) return null;
+
+ return {
+ html: `<span class="property">${match[0]}</span>`,
+ next: match[0].length
+ };
+ },
+
+ // keyword
+ (code, i, source) => {
+ const prev = source[i - 1];
+ if (prev && /[a-zA-Z]/.test(prev)) return null;
+
+ const match = keywords.filter(k => code.substr(0, k.length) == k)[0];
+ if (match) {
+ if (/^[a-zA-Z]/.test(code.substr(match.length))) return null;
+ return {
+ html: `<span class="keyword ${match}">${match}</span>`,
+ next: match.length
+ };
+ } else {
+ return null;
+ }
+ },
+
+ // symbol
+ code => {
+ const match = symbols.filter(s => code[0] == s)[0];
+ if (match) {
+ return {
+ html: `<span class="symbol">${match}</span>`,
+ next: 1
+ };
+ } else {
+ return null;
+ }
+ }
+];
+
+// specify lang is todo
+export default (source: string, lang?: string) => {
+ let code = source;
+ let html = '';
+
+ let i = 0;
+
+ function push(token) {
+ html += token.html;
+ code = code.substr(token.next);
+ i += token.next;
+ }
+
+ while (code != '') {
+ const parsed = elements.some(el => {
+ const e = el(code, i, source);
+ if (e) {
+ push(e);
+ return true;
+ } else {
+ return false;
+ }
+ });
+
+ if (!parsed) {
+ push({
+ html: escape(code[0]),
+ next: 1
+ });
+ }
+ }
+
+ return html;
+};
diff --git a/src/server/api/common/text/elements/bold.ts b/src/server/api/common/text/elements/bold.ts
new file mode 100644
index 0000000000..ce25764457
--- /dev/null
+++ b/src/server/api/common/text/elements/bold.ts
@@ -0,0 +1,14 @@
+/**
+ * Bold
+ */
+
+module.exports = text => {
+ const match = text.match(/^\*\*(.+?)\*\*/);
+ if (!match) return null;
+ const bold = match[0];
+ return {
+ type: 'bold',
+ content: bold,
+ bold: bold.substr(2, bold.length - 4)
+ };
+};
diff --git a/src/server/api/common/text/elements/code.ts b/src/server/api/common/text/elements/code.ts
new file mode 100644
index 0000000000..4821e95fe2
--- /dev/null
+++ b/src/server/api/common/text/elements/code.ts
@@ -0,0 +1,17 @@
+/**
+ * Code (block)
+ */
+
+import genHtml from '../core/syntax-highlighter';
+
+module.exports = text => {
+ const match = text.match(/^```([\s\S]+?)```/);
+ if (!match) return null;
+ const code = match[0];
+ return {
+ type: 'code',
+ content: code,
+ code: code.substr(3, code.length - 6).trim(),
+ html: genHtml(code.substr(3, code.length - 6).trim())
+ };
+};
diff --git a/src/server/api/common/text/elements/emoji.ts b/src/server/api/common/text/elements/emoji.ts
new file mode 100644
index 0000000000..e24231a223
--- /dev/null
+++ b/src/server/api/common/text/elements/emoji.ts
@@ -0,0 +1,14 @@
+/**
+ * Emoji
+ */
+
+module.exports = text => {
+ const match = text.match(/^:[a-zA-Z0-9+-_]+:/);
+ if (!match) return null;
+ const emoji = match[0];
+ return {
+ type: 'emoji',
+ content: emoji,
+ emoji: emoji.substr(1, emoji.length - 2)
+ };
+};
diff --git a/src/server/api/common/text/elements/hashtag.ts b/src/server/api/common/text/elements/hashtag.ts
new file mode 100644
index 0000000000..ee57b140b8
--- /dev/null
+++ b/src/server/api/common/text/elements/hashtag.ts
@@ -0,0 +1,19 @@
+/**
+ * Hashtag
+ */
+
+module.exports = (text, i) => {
+ if (!(/^\s#[^\s]+/.test(text) || (i == 0 && /^#[^\s]+/.test(text)))) return null;
+ const isHead = text[0] == '#';
+ const hashtag = text.match(/^\s?#[^\s]+/)[0];
+ const res: any[] = !isHead ? [{
+ type: 'text',
+ content: text[0]
+ }] : [];
+ res.push({
+ type: 'hashtag',
+ content: isHead ? hashtag : hashtag.substr(1),
+ hashtag: isHead ? hashtag.substr(1) : hashtag.substr(2)
+ });
+ return res;
+};
diff --git a/src/server/api/common/text/elements/inline-code.ts b/src/server/api/common/text/elements/inline-code.ts
new file mode 100644
index 0000000000..9f9ef51a2b
--- /dev/null
+++ b/src/server/api/common/text/elements/inline-code.ts
@@ -0,0 +1,17 @@
+/**
+ * Code (inline)
+ */
+
+import genHtml from '../core/syntax-highlighter';
+
+module.exports = text => {
+ const match = text.match(/^`(.+?)`/);
+ if (!match) return null;
+ const code = match[0];
+ return {
+ type: 'inline-code',
+ content: code,
+ code: code.substr(1, code.length - 2).trim(),
+ html: genHtml(code.substr(1, code.length - 2).trim())
+ };
+};
diff --git a/src/server/api/common/text/elements/link.ts b/src/server/api/common/text/elements/link.ts
new file mode 100644
index 0000000000..35563ddc3d
--- /dev/null
+++ b/src/server/api/common/text/elements/link.ts
@@ -0,0 +1,19 @@
+/**
+ * Link
+ */
+
+module.exports = text => {
+ const match = text.match(/^\??\[([^\[\]]+?)\]\((https?:\/\/[\w\/:%#@\$&\?!\(\)\[\]~\.=\+\-]+?)\)/);
+ if (!match) return null;
+ const silent = text[0] == '?';
+ const link = match[0];
+ const title = match[1];
+ const url = match[2];
+ return {
+ type: 'link',
+ content: link,
+ title: title,
+ url: url,
+ silent: silent
+ };
+};
diff --git a/src/server/api/common/text/elements/mention.ts b/src/server/api/common/text/elements/mention.ts
new file mode 100644
index 0000000000..2025dfdaad
--- /dev/null
+++ b/src/server/api/common/text/elements/mention.ts
@@ -0,0 +1,17 @@
+/**
+ * Mention
+ */
+import parseAcct from '../../../../common/user/parse-acct';
+
+module.exports = text => {
+ const match = text.match(/^(?:@[a-zA-Z0-9\-]+){1,2}/);
+ if (!match) return null;
+ const mention = match[0];
+ const { username, host } = parseAcct(mention.substr(1));
+ return {
+ type: 'mention',
+ content: mention,
+ username,
+ host
+ };
+};
diff --git a/src/server/api/common/text/elements/quote.ts b/src/server/api/common/text/elements/quote.ts
new file mode 100644
index 0000000000..cc8cfffdc4
--- /dev/null
+++ b/src/server/api/common/text/elements/quote.ts
@@ -0,0 +1,14 @@
+/**
+ * Quoted text
+ */
+
+module.exports = text => {
+ const match = text.match(/^"([\s\S]+?)\n"/);
+ if (!match) return null;
+ const quote = match[0];
+ return {
+ type: 'quote',
+ content: quote,
+ quote: quote.substr(1, quote.length - 2).trim(),
+ };
+};
diff --git a/src/server/api/common/text/elements/url.ts b/src/server/api/common/text/elements/url.ts
new file mode 100644
index 0000000000..1003aff9c3
--- /dev/null
+++ b/src/server/api/common/text/elements/url.ts
@@ -0,0 +1,14 @@
+/**
+ * URL
+ */
+
+module.exports = text => {
+ const match = text.match(/^https?:\/\/[\w\/:%#@\$&\?!\(\)\[\]~\.=\+\-]+/);
+ if (!match) return null;
+ const url = match[0];
+ return {
+ type: 'url',
+ content: url,
+ url: url
+ };
+};
diff --git a/src/server/api/common/text/index.ts b/src/server/api/common/text/index.ts
new file mode 100644
index 0000000000..1e2398dc38
--- /dev/null
+++ b/src/server/api/common/text/index.ts
@@ -0,0 +1,72 @@
+/**
+ * Misskey Text Analyzer
+ */
+
+const elements = [
+ require('./elements/bold'),
+ require('./elements/url'),
+ require('./elements/link'),
+ require('./elements/mention'),
+ require('./elements/hashtag'),
+ require('./elements/code'),
+ require('./elements/inline-code'),
+ require('./elements/quote'),
+ require('./elements/emoji')
+];
+
+export default (source: string) => {
+
+ if (source == '') {
+ return null;
+ }
+
+ const tokens = [];
+
+ function push(token) {
+ if (token != null) {
+ tokens.push(token);
+ source = source.substr(token.content.length);
+ }
+ }
+
+ let i = 0;
+
+ // パース
+ while (source != '') {
+ const parsed = elements.some(el => {
+ let _tokens = el(source, i);
+ if (_tokens) {
+ if (!Array.isArray(_tokens)) {
+ _tokens = [_tokens];
+ }
+ _tokens.forEach(push);
+ return true;
+ } else {
+ return false;
+ }
+ });
+
+ if (!parsed) {
+ push({
+ type: 'text',
+ content: source[0]
+ });
+ }
+
+ i++;
+ }
+
+ // テキストを纏める
+ tokens[0] = [tokens[0]];
+ return tokens.reduce((a, b) => {
+ if (a[a.length - 1].type == 'text' && b.type == 'text') {
+ const tail = a.pop();
+ return a.concat({
+ type: 'text',
+ content: tail.content + b.content
+ });
+ } else {
+ return a.concat(b);
+ }
+ });
+};
diff --git a/src/server/api/common/watch-post.ts b/src/server/api/common/watch-post.ts
new file mode 100644
index 0000000000..1a50f0edaa
--- /dev/null
+++ b/src/server/api/common/watch-post.ts
@@ -0,0 +1,26 @@
+import * as mongodb from 'mongodb';
+import Watching from '../models/post-watching';
+
+export default async (me: mongodb.ObjectID, post: object) => {
+ // 自分の投稿はwatchできない
+ if (me.equals((post as any).user_id)) {
+ return;
+ }
+
+ // if watching now
+ const exist = await Watching.findOne({
+ post_id: (post as any)._id,
+ user_id: me,
+ deleted_at: { $exists: false }
+ });
+
+ if (exist !== null) {
+ return;
+ }
+
+ await Watching.insert({
+ created_at: new Date(),
+ post_id: (post as any)._id,
+ user_id: me
+ });
+};