Sotaro KARASAWA 11 years ago
commit
c0f47c5f82
80 changed files with 7455 additions and 0 deletions
  1. 5 0
      .gitignore
  2. 14 0
      .jshintrc
  3. 100 0
      CHANGES.md
  4. 114 0
      Gruntfile.js
  5. 1 0
      Procfile
  6. 58 0
      README.md
  7. 22 0
      TODO
  8. 160 0
      app.js
  9. 73 0
      bin/migration/0.0.1-0.0.2-convert_embedded_object_to_schema.js
  10. 25 0
      bower.json
  11. 44 0
      config/default.js.dist
  12. 111 0
      doc/01-install-and-config.md
  13. 1 0
      doc/02-developer-reference.md
  14. 5 0
      doc/index.md
  15. 7 0
      form/index.js
  16. 9 0
      form/login.js
  17. 10 0
      form/me/password.js
  18. 9 0
      form/me/user.js
  19. 13 0
      form/register.js
  20. 9 0
      form/revision.js
  21. 37 0
      lib/fileUploader.js
  22. 61 0
      lib/googleAuth.js
  23. 27 0
      lib/middlewares.js
  24. 55 0
      lib/swig_functions.js
  25. 58 0
      models/bookmark.js
  26. 12 0
      models/index.js
  27. 416 0
      models/page.js
  28. 74 0
      models/revision.js
  29. 332 0
      models/user.js
  30. 61 0
      package.json
  31. BIN
      public/apple-touch-icon-114x114-precomposed.png
  32. BIN
      public/apple-touch-icon-57x57-precomposed.png
  33. BIN
      public/apple-touch-icon-72x72-precomposed.png
  34. BIN
      public/apple-touch-icon-precomposed.png
  35. BIN
      public/apple-touch-icon.png
  36. 1 0
      public/css/.gitignore
  37. BIN
      public/favicon.ico
  38. 1 0
      public/fonts
  39. 3 0
      public/hoge.css
  40. BIN
      public/images/loading_l.gif
  41. BIN
      public/images/loading_s.gif
  42. BIN
      public/images/userpicture.png
  43. 1 0
      public/js/.gitignore
  44. 82 0
      public/nginx-mime.types
  45. 126 0
      public/nginx.conf
  46. 10 0
      resource/css/_admin.scss
  47. 26 0
      resource/css/_form.scss
  48. 537 0
      resource/css/_layout.scss
  49. 12 0
      resource/css/_mixins.scss
  50. 654 0
      resource/css/_variables.scss
  51. 191 0
      resource/css/_wiki.scss
  52. 121 0
      resource/css/crowi-reveal.scss
  53. 358 0
      resource/css/crowi.scss
  54. 256 0
      resource/js/crowi.js
  55. 145 0
      routes/admin.js
  56. 55 0
      routes/index.js
  57. 217 0
      routes/login.js
  58. 11 0
      routes/logout.js
  59. 239 0
      routes/me.js
  60. 323 0
      routes/page.js
  61. 41 0
      routes/user.js
  62. 13 0
      views/500.html
  63. 94 0
      views/_form.html
  64. 37 0
      views/admin/index.html
  65. 164 0
      views/admin/users.html
  66. 15 0
      views/index.html
  67. 261 0
      views/layout/2column.html
  68. 124 0
      views/layout/layout.html
  69. 22 0
      views/layout/single-nologin.html
  70. 1 0
      views/layout/single.html
  71. 256 0
      views/login.html
  72. 362 0
      views/me/index.html
  73. 102 0
      views/me/password.html
  74. 113 0
      views/modal/widget_help.html
  75. 47 0
      views/modal/widget_rename.html
  76. 28 0
      views/modal/widget_today_memo.html
  77. 327 0
      views/page.html
  78. 81 0
      views/page_list.html
  79. 73 0
      views/page_presentation.html
  80. 2 0
      views/user_page.html

+ 5 - 0
.gitignore

@@ -0,0 +1,5 @@
+config/default.js
+node_modules/
+bower_components/
+public/js/*
+public/css/*

+ 14 - 0
.jshintrc

@@ -0,0 +1,14 @@
+{
+  "curly": true,
+  "eqnull": false,
+  "forin": true,
+  "freeze": true,
+  "immed": true,
+  "indent": 2,
+  "laxcomma": true,
+  "newcap": true,
+  "node": true,
+  "quotmark": "single",
+  "trailing": true,
+  "undef": true
+}

+ 100 - 0
CHANGES.md

@@ -0,0 +1,100 @@
+CHANGES
+========
+
+## 1.0.3
+
+* Feature: Page access control
+* Fix: Upgrade twbs and fixed popover problem
+
+## 1.0.2
+
+* Feature: Use SCSS instead of LESS
+* Improve: Style of presentation mode
+
+## 1.0.1
+
+* Feature: Printable style
+* Fix: Added tmp dir to repository and set cache dir option to googleapi.
+* Fix: Responsive styles
+* Fix: GitHub linker
+
+## 1.0.0
+
+* Feature: GitHub issue link syntax (`[userOrOrgName/repo#issue]`)
+* Feature: User login restriction and E-mail registration
+    * User can now update the information themselves
+* Feature: Presentation mode (thanks. @riaf)
+* Feature: Hide sidebar
+* Feature: Upload user picture by themselves
+* Improve: styles
+
+### Configurations
+
+* Added `security` section
+    * `security.passwordSeed`
+    * `security.registrationWhiteList`
+    * `security.confidential`
+* Added `aws` section
+    * `aws.bucket`: S3 bucket
+    * `aws.region`: Region
+    * `aws.accessKeyId`
+    * `aws.secretAccessKey`
+
+### B.C.
+
+* Configuration name changed: `app.confidential` to `security.confidential`
+
+
+## 0.9.6
+
+* Fix some bugs
+
+## 0.9.5
+
+* Fix: pager
+* Improve affix style
+
+## 0.9.4
+
+* Feature: Page conflict check
+* Feature: Sticky page header
+* Feature: Search on header and popup
+* Fix: URL detect with x-forwarded-proto header
+
+## 0.9.3
+
+* Feature: Added link create modal: Easy to create today's memo
+* Feature: Generate clickable and copieable link
+* Feature: Page rename
+* Feature: Help modal
+* Fix: Express configuration
+
+## 0.9.2
+
+* Bug Fix: Fatal error on session recover
+
+## 0.9.1
+
+* Update design
+* Upgrade dependencies
+* Compiling LESS and JS files with Grunt
+
+## 0.2.0
+
+* Use revision schema instead of embedded document
+* Show table of contents
+* Preview on editting
+* Insert 4 space when TAB key pressed on editting
+
+
+### Migration
+
+* npm install async
+
+run:
+
+    $ node bin/migration/0.0.1-0.0.2-convert_embedded_object_to_schema.js
+
+## 0.1.0
+
+* Initial Release

+ 114 - 0
Gruntfile.js

@@ -0,0 +1,114 @@
+/*
+ * @package RMW
+ */
+
+module.exports = function(grunt) {
+
+  var paths = {
+        scripts: ['Gruntfile.js', 'app.js', 'lib/**/*.js', 'models/**/*.js', 'routes/**/*.js', 'form/**/*.js', 'resource/js/**/*.js'],
+        styles: ['resource/css/*.scss'],
+        all: []
+      };
+
+  Object.keys(paths).forEach(function(name) {
+    paths[name].forEach(function(path) {
+      paths.all[paths.all.length] = path;
+    });
+  });
+
+  // Project configuration.
+  grunt.initConfig({
+    pkg: grunt.file.readJSON('package.json'),
+    dirs: {
+      js: 'resource/js',
+      jsDest: 'public/js',
+      css: 'resource/css',
+      cssDest: 'public/css',
+      web: 'public/'
+    },
+    sass: {
+      dev: {
+        options: {
+          outputStyle: 'nested',
+          includePaths: [
+            'bower_components/bootstrap-sass-official/assets/stylesheets',
+            'bower_components/fontawesome/scss'
+          ]
+        },
+        files: {
+          '<%= dirs.cssDest %>/<%= pkg.name %>.css': '<%= dirs.css %>/<%= pkg.name %>.scss',
+          '<%= dirs.cssDest %>/<%= pkg.name %>-reveal.css': '<%= dirs.css %>/<%= pkg.name %>-reveal.scss'
+        }
+      },
+      default: {
+        options: {
+          outputStyle: 'compressed',
+          includePaths: [
+            'bower_components/bootstrap-sass-official/assets/stylesheets',
+            'bower_components/fontawesome/scss'
+          ]
+        },
+        files: {
+          '<%= dirs.cssDest %>/<%= pkg.name %>.min.css': '<%= dirs.css %>/<%= pkg.name %>.scss',
+          '<%= dirs.cssDest %>/<%= pkg.name %>-reveal.min.css': '<%= dirs.css %>/<%= pkg.name %>-reveal.scss'
+        }
+      }
+    },
+    concat: {
+      dist: {
+        src: [
+          // Bootstrap
+          'bower_components/bootstrap-sass-official/assets/javascripts/bootstrap.js',
+          // socket.io
+          'node_modules/socket.io-client/dist/socket.io.js',
+          // markd
+          'node_modules/marked/lib/marked.js',
+          // jquery.cookie
+          'node_modules/jquery.cookie/jquery.cookie.js',
+          // crowi
+          'resource/js/crowi.js'
+        ],
+        dest: '<%= dirs.jsDest %>/<%= pkg.name %>.js'
+      }
+    },
+    uglify: {
+      build: {
+        src: '<%= concat.dist.dest %>',
+        dest: '<%= dirs.jsDest %>/<%= pkg.name %>.min.js'
+      }
+    },
+    jshint: {
+      options: {
+        jshintrc: true
+      },
+      all: ['Gruntfile.js', 'lib/**/*.js', 'models/**/*.js', 'routes/**/*.js', 'form/**/*.js', 'resource/js/**/*.js']
+    },
+    watch: {
+      css: {
+        files: paths.styles,
+        tasks: ['sass'],
+      },
+      dev: {
+        files: paths.all,
+        tasks: ['dev'],
+      },
+      default: {
+        files: paths.all,
+        tasks: ['default'],
+      },
+    },
+  });
+
+
+  grunt.loadNpmTasks('grunt-contrib-uglify');
+  grunt.loadNpmTasks('grunt-contrib-watch');
+  grunt.loadNpmTasks('grunt-contrib-concat');
+  grunt.loadNpmTasks('grunt-contrib-jshint');
+  grunt.loadNpmTasks('grunt-sass');
+
+
+  // grunt watch dev
+  grunt.registerTask('default', ['sass', 'concat', 'uglify']);
+  grunt.registerTask('dev', ['jshint', 'sass:dev', 'concat']);
+
+};

+ 1 - 0
Procfile

@@ -0,0 +1 @@
+web: node app.js

+ 58 - 0
README.md

@@ -0,0 +1,58 @@
+Crowi - The Simple and Powerful Communication Tool Based on Wiki
+================================================================
+
+Crowi is:
+
+* Easy to edit and share,
+* Markdown supported,
+* Useful timeline list view,
+* Fast.
+
+Install
+---------
+
+Install dependencies and build CSS and JavaScript:
+
+    $ npm install .
+    $ bower install
+    $ grunt
+
+Configuration:
+
+    $ cp config/default.js.dist config/default.js
+
+More info are [here](doc/index.md).
+
+Dependencies
+-------------
+
+* Node.js
+* MongoDB
+* Amazon S3
+* Facebook Application (optional)
+* Google Project (optional)
+
+License
+---------
+
+> The MIT License (MIT)
+>
+> Copyright (c) 2013 Sotaro KARASAWA <sotarok@crocos.co.jp>
+>
+> Permission is hereby granted, free of charge, to any person obtaining a copy
+> of this software and associated documentation files (the "Software"), to deal
+> in the Software without restriction, including without limitation the rights
+> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+> copies of the Software, and to permit persons to whom the Software is
+> furnished to do so, subject to the following conditions:
+>
+> The above copyright notice and this permission notice shall be included in
+> all copies or substantial portions of the Software.
+>
+> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+> THE SOFTWARE.

+ 22 - 0
TODO

@@ -0,0 +1,22 @@
+TODO
+=====
+
+* ~~User create~~
+* ~~Page edit~~
+* ~~List view~~
+* ~~Page title as a separated link~~
+* ~~Page fromatter~~
+    * ~~Text (auto link, auto image expand)~~
+* ~~Timeline list view~~
+* Page move
+* Page copy
+* Page fromatter
+    * ~~Hatena~~
+    * ~~Markdown~~
+* Search
+* Pagination
+* Realtime preview on page edit
+* Page revert (from history)
+* History diff view
+* Ajax edit
+* Live edit

+ 160 - 0
app.js

@@ -0,0 +1,160 @@
+/**
+ * Fakie::app.js
+ *
+ * @package Fakie
+ * @author  Sotaro KARASAWA <sotarok@crocos.co.jp>
+ */
+
+var express  = require('express');
+var cons     = require('consolidate');
+var swig     = require('swig');
+var flash    = require('connect-flash');
+var config   = require('config');
+var http     = require('http');
+var facebook = require('facebook-node-sdk');
+var mongo    = require('mongoose');
+var socketio = require('socket.io');
+
+var time     = require('time');
+time.tzset('Asia/Tokyo');
+tzoffset = -(config.app.timezone || 9) * 60; // for datez
+
+var app = express();
+
+mongo.connect('mongodb://' + config.mongodb.user + ':' + config.mongodb.password + '@' + config.mongodb.host + '/' + config.mongodb.dbname);
+
+
+// swig
+// TODO どっかに移す
+swig.setFilter('path2name', function(string) {
+  return string.replace(/.+\/(.+)?$/, "$1");
+});
+swig.setFilter('datetz', function(input, format) {
+  // デフォルトの filter の override するにはどうしたらいいんだろうかね
+  var swigFilters     = require('swig/lib/filters')
+  return swigFilters.date(input, format, tzoffset);
+});
+swig.setFilter('presentation', function(string) {
+  // 手抜き
+  return string.replace(/[\n]+#/g, "\n\n\n#");
+});
+swig.setFilter('picture', function(user) {
+  if (!user) {
+    return '';
+  }
+
+  user.fbId = user.userId; // migration
+  if (user.image && user.image != '/images/userpicture.png') {
+    return user.image;
+  } else if (user.fbId) {
+    return '//graph.facebook.com/' + user.fbId + '/picture?size=square';
+  } else {
+    return '/images/userpicture.png';
+  }
+});
+
+app.configure(function(){
+  var models;
+
+  app.set('port', config.server.port || 3000);
+  app.engine('html', cons.swig);
+  app.set('view cache', false);
+  app.set('view engine', 'html');
+  app.set('views', __dirname + '/views');
+  app.use(express.methodOverride());
+  app.use(express.bodyParser());
+  app.use(express.cookieParser());
+  app.use(express.session({
+    rolling: true,
+    secret: config.session.secret,
+  }));
+  app.use(flash());
+  app.use(facebook.middleware({appId: config.facebook.appId, secret: config.facebook.secret}));
+  models = require('./models')(app);
+  app.use(function(req, res, next) {
+    var days = (1000*3600*24*30);
+    req.session.cookie.expires = new Date(Date.now() + days);
+    req.session.cookie.maxAge = days;
+
+    var now = new Date();
+
+    req.config = config;
+    req.baseUrl = (req.headers['x-forwarded-proto'] == 'https' ? 'https' : req.protocol) + "://" + req.get('host');
+    res.locals({
+      req: req,
+      baseUrl: req.baseUrl,
+      config: config,
+      env: app.get('env'),
+      now: now,
+      tzoffset: tzoffset,
+      facebook: {appId: config.facebook.appId},
+      consts: {
+        pageGrants: models.Page.getGrantLabels(),
+        userStatus: models.User.getUserStatusLabels(),
+      },
+    });
+    next();
+  });
+
+  // register swig function
+  app.use(function(req, res, next) {
+    res.locals(require('./lib/swig_functions')(app));
+    next();
+  });
+
+  app.use(function(req, res, next) {
+    // session に user object が入ってる
+    if (req.session.user && '_id' in req.session.user) {
+      models.User.findById(req.session.user._id, function(err, userData) {
+        if (err) {
+          next()
+        } else {
+          req.user = req.session.user = userData;
+          res.locals({user: req.user});
+          next();
+        }
+      });
+    } else {
+      req.user = req.session.user = false;
+      res.locals({user: req.user});
+      next();
+    }
+  });
+
+  app.use(express.static(__dirname + '/public'));
+  app.use(app.router);
+});
+
+app.configure('development', function(){
+  swig.setDefaults({ cache: false });
+  app.use(express.errorHandler({ dumpExceptions: true, showStack: true }));
+
+});
+
+app.configure('production', function(){
+  var oneYear = 31557600000;
+
+  // Error Handler
+  app.use(function (err, req, res, next) {
+    res.status(500);
+    res.render('500', { error: err });
+  });
+});
+
+var server = 1;
+if (app.get('env') == 'development') {
+  server = http.createServer(app).listen(app.get('port'), function(){
+    console.log("[" + app.get('env') + "] Express server listening on port " + app.get('port'));
+  });
+} else {
+  server = http.createServer(app).listen(app.get('port'), '127.0.0.1', function(){
+    console.log("[" + app.get('env') + "] Express server listening on port " + app.get('port'));
+  });
+}
+
+var io = socketio.listen(server);
+io.sockets.on('connection', function (socket) {
+});
+
+app.set('io', io);
+require('./routes')(app);

+ 73 - 0
bin/migration/0.0.1-0.0.2-convert_embedded_object_to_schema.js

@@ -0,0 +1,73 @@
+/**
+ * Fakie::app.js
+ *
+ * @package Fakie
+ * @author  Sotaro KARASAWA <sotarok@crocos.co.jp>
+ * @version 0.0.0
+ */
+
+var util    = require('util');
+var config  = require('config');
+var mongo   = require('mongoose');
+var async   = require('async');
+
+var user  =     require('../../lib/user');
+var page  =     require('../../lib/page');
+var revision  = require('../../lib/revision');
+
+mongo.connect('mongodb://' + config.mongodb.user + ':' + config.mongodb.password + '@' + config.mongodb.host + '/' + config.mongodb.dbname);
+module.exports = {
+  user: user
+  ,page: page
+  ,revision: revision
+};
+
+
+var options = {
+  offset: 0,
+  limit : 999,
+  revisionSlice: {$slice: 9999}
+};
+
+var q = page.Page.findListByStartWith('/', options, function(err, docs) {
+  var i = 0;
+  async.forEach(docs, function(data, cb) {
+    var ii = 0;
+    var path = data.path;
+    var pageId = data._id;
+    console.log("path: ", data._id, path);
+
+    async.forEachSeries(data.revisions, function(rData, rcb) {
+      var newRevision = new revision.Revision();
+      newRevision.path      = path;
+      newRevision.body      = rData.body;
+      newRevision.format    = rData.format;
+      newRevision.createdAt = rData.createdAt;
+      newRevision.save(function (err, n) {
+        if (!err) {
+          console.log("    ", path, ii++, rData.createdAt, " ... ok", n._id);
+        } else {
+          console.log("    ", path, ii++, rData.createdAt, " ... ERROR!");
+          console.log(err);
+        }
+        rcb();
+      });
+      //rcb();
+    }, function (rErr) {
+      console.log("    ", path, " all revision imported.");
+      revision.Revision.findLatestRevision(path, function(err, fr) {
+        page.Page.updateRevision(pageId, fr._id, function(err, frr) {
+          if (!err) {
+            console.log("        Page revision updated", pageId, path, i++);
+          } else {
+            console.log("        Page revision update ERROR", pageId, path, i++);
+          }
+          cb();
+        });
+      });
+    });
+  }, function(err) {
+    console.log('end');
+    mongo.disconnect();
+  });
+});

+ 25 - 0
bower.json

@@ -0,0 +1,25 @@
+{
+  "name": "crowi",
+  "version": "1.0.3",
+  "description": "Crocos' Wiki implementation in node.js",
+  "authors": [
+    "Sotaro KARASAWA <sotarok@crocos.co.jp>",
+    "Keisuke SATO <riaf@me.com>"
+  ],
+  "keywords": [
+    "wiki"
+  ],
+  "license": "MIT",
+  "private": true,
+  "ignore": [
+    "**/.*",
+    "node_modules",
+    "bower_components",
+    "test",
+    "tests"
+  ],
+  "dependencies": {
+    "bootstrap-sass-official": "~3.2.0",
+    "fontawesome": "~4.1.0"
+  }
+}

+ 44 - 0
config/default.js.dist

@@ -0,0 +1,44 @@
+/**
+ * default config
+ */
+
+module.exports = {
+  server: {
+    port: 3000
+  },
+  app: {
+    title: 'Crocos Wiki'
+  },
+  security: {
+    confidential: '',
+    passwordSeed: "j9a5gt", // please change here
+    registrationMode: 'Restricted', // Open, Restricted, Closed
+    registrationWhiteList: []
+  },
+  aws: {
+    bucket: 'crowi',
+    region: 'ap-northeast-1',
+    accessKeyId: '',
+    secretAccessKey: ''
+  },
+  mongodb: {
+    host: 'localhost',
+    dbname: 'crowi',
+    user: '',
+    password: ''
+  },
+  searcher: {
+    url: 'https:// ...' // crocos-wikis
+  },
+  google: {
+    clientId: '',
+    clientSecret: ''
+  },
+  facebook: {
+    appId: '',
+    secret: ''
+  },
+  session: {
+    secret: 'please input here some string',
+  }
+}

+ 111 - 0
doc/01-install-and-config.md

@@ -0,0 +1,111 @@
+# Install and Configuration
+
+## Install
+
+To install, just set up dependencies after clone.
+
+    $ npm install .
+    $ git submodule update --init
+
+
+## Copy Configuration File
+
+First, copy `.dist` file to the own config file.
+
+    $ cp config/default.js.dist config/default.js
+
+
+## Configuration List
+
+Here is an original distributed configuration.
+
+```
+{
+  server: {
+    port: 3000
+  },
+  app: {
+    title: 'Crocos Wiki'
+  },
+  security: {
+    confidential: '',
+    passwordSeed: "j9a5gt", // please change here
+    registrationWhiteList: [
+    ]
+  },
+  aws: {
+    bucket: 'crowi',
+    region: 'ap-northeast-1',
+    accessKeyId: '',
+    secretAccessKey: ''
+  },
+  mongodb: {
+    host: 'localhost',
+    dbname: 'crowi',
+    user: '',
+    password: ''
+  },
+  searcher: {
+    url: 'https:// ...' // crocos-wikis
+  },
+  google: {
+    clientId: '',
+    clientSecret: ''
+  },
+  facebook: {
+    appId: '',
+    secret: ''
+  },
+  session: {
+    secret: 'please input here some string',
+  }
+}
+```
+
+## Set Up Facebook Application
+
+to be writte.
+
+
+## Set Up Google Project
+
+Enable configuration of Google, you can register and login by using google account.
+Configure `google` section,
+
+Registration/Login using Google account is also affected a whilelist setting on `security` section. You can restrict registrant's email by only listed emails. This setting is useful if you use Google Apps on your organization.
+
+
+For more help, go to the official document.
+[Google Developers Console Help](https://developers.google.com/console/help/new/#generatingoauth2)
+
+### Create Project
+
+To set up your project's consent screen, do the following:
+
+1. Go to the [Google Developers Console](https://console.developers.google.com/project).
+2. Select **Create Project**.
+3. Fill forms and click **Create**.
+
+
+### Create New Client ID
+
+In the sidebar on the left, select Credentials, then select **Create New Client ID**.
+
+1. Select **Web Application**
+2. Fill forms as below:
+
+   If you set up this wiki on `wiki.example.com`, fill the as below:
+
+       https://wiki.example.com
+
+   And fill 'Redirect URL' field as below:
+
+       https://wiki.example.com/google/callback
+
+
+So, now you can get **Client ID** and **Client Secret**, copy these values and paste it on `config/default.js`.
+
+### Set Up Consent Screen
+
+Make sure that the email is set on **EMAIL ADDRESSS** field.
+

+ 1 - 0
doc/02-developer-reference.md

@@ -0,0 +1 @@
+# Developer Reference

+ 5 - 0
doc/index.md

@@ -0,0 +1,5 @@
+# Documentation
+
+* [Install and Configuration](01-install-and-config.md)
+* [Developer Reference](02-developer-reference.md)
+

+ 7 - 0
form/index.js

@@ -0,0 +1,7 @@
+exports.login = require('./login');
+exports.register = require('./register');
+exports.revision = require('./revision');
+exports.me = {
+  user: require('./me/user'),
+  password: require('./me/password')
+};

+ 9 - 0
form/login.js

@@ -0,0 +1,9 @@
+'use strict';
+
+var form = require('express-form')
+  , field = form.field;
+
+module.exports = form(
+  field('loginForm.email').required(),
+  field('loginForm.password').required().is(/^[\da-zA-Z@#$%-_&\+\*\?]{6,40}$/)
+);

+ 10 - 0
form/me/password.js

@@ -0,0 +1,10 @@
+'use strict';
+
+var form = require('express-form')
+  , field = form.field;
+
+module.exports = form(
+  field('mePassword.oldPassword'),
+  field('mePassword.newPassword').required().is(/^[\da-zA-Z@#$%-_&\+\*\?]{6,40}$/),
+  field('mePassword.newPasswordConfirm').required()
+);

+ 9 - 0
form/me/user.js

@@ -0,0 +1,9 @@
+'use strict';
+
+var form = require('express-form')
+  , field = form.field;
+
+module.exports = form(
+  field('userForm.name').trim().required(),
+  field('userForm.email').trim().isEmail().required()
+);

+ 13 - 0
form/register.js

@@ -0,0 +1,13 @@
+'use strict';
+
+var form = require('express-form')
+  , field = form.field;
+
+module.exports = form(
+  field('registerForm.username').required().is(/^[\da-zA-Z\-_]+$/),
+  field('registerForm.name').required(),
+  field('registerForm.email').required(),
+  field('registerForm.password').required().is(/^[\da-zA-Z@#$%-_&\+\*\?]{6,40}$/),
+  field('registerForm.fbId').isInt(),
+  field('registerForm.googleId').isInt()
+);

+ 9 - 0
form/revision.js

@@ -0,0 +1,9 @@
+'use strict';
+
+var form = require('express-form')
+  , field = form.field;
+
+module.exports = form(
+  field('pageForm.body').required()
+  //field('pageForm.hoge').required()
+);

+ 37 - 0
lib/fileUploader.js

@@ -0,0 +1,37 @@
+/**
+ * fileUploader
+ */
+
+var aws = require('aws-sdk');
+var config = require('config');
+
+module.exports = {
+  // deleteFile: function(filePath, callback) {
+  //   // TODO 実装する
+  // },
+  uploadFile: function(filePath, contentType, fileStream, options, callback) {
+    var awsConfig = config.aws;
+    aws.config.update({
+      accessKeyId: awsConfig.accessKeyId,
+      secretAccessKey: awsConfig.secretAccessKey,
+      region: awsConfig.region
+    });
+    var s3 = new aws.S3();
+
+    var params = {Bucket: awsConfig.bucket};
+    params.ContentType = contentType;
+    params.Key = filePath;
+    params.Body = fileStream;
+    params.ACL = 'public-read';
+
+    s3.putObject(params, function(err, data) {
+      callback(err, data);
+    });
+  },
+  generateS3FillUrl: function(filePath) {
+    var awsConfig = config.aws;
+    var url = 'https://' + awsConfig.bucket +'.s3.amazonaws.com/' + filePath;
+
+    return url;
+  }
+};

+ 61 - 0
lib/googleAuth.js

@@ -0,0 +1,61 @@
+/**
+ * googleAuth utility
+ */
+
+'use strict';
+
+var googleapis = require('googleapis');
+var config   = require('config');
+
+function createOauth2Client(url) {
+  return new googleapis.auth.OAuth2Client(
+    config.google.clientId,
+    config.google.clientSecret,
+    url
+  );
+}
+
+module.exports = {
+  createAuthUrl: function(req, callback) {
+    var callbackUrl = req.baseUrl + '/google/callback';
+    var google = createOauth2Client(callbackUrl);
+
+    var redirectUrl = google.generateAuthUrl({
+      access_type: 'offline',
+      scope: 'https://www.googleapis.com/auth/userinfo.email',
+    });
+
+    callback(null, redirectUrl);
+  },
+  handleCallback: function(req, callback) {
+    var callbackUrl = req.baseUrl + '/google/callback';
+    var google = createOauth2Client(callbackUrl);
+    var code = req.session.googleAuthCode || null;
+
+    if (!code) {
+      return callback(new Error('No code exists.'), null);
+    }
+
+    google.getToken(code, function(err, tokens) {
+      console.log('getToken', err, tokens);
+      if (err) {
+        return callback(new Error('[googleAuth.handleCallback] Error to get token.'), null);
+      }
+
+      googleapis.discover('oauth2', 'v1').withOpts({cache: { path: __dirname + '/../tmp/googlecache'}}).execute(function(err, client) {
+        if (err) {
+          return callback(new Error('[googleAuth.handleCallback] Failed to discover oauth2 API endpoint.'), null);
+        }
+
+        var tokeninfo = client.oauth2.tokeninfo({id_token: tokens.id_token});
+        tokeninfo.execute(function(err, response) {
+          if (err) {
+            return callback(new Error('[googleAuth.handleCallback] Error while proceccing tokeninfo.'), null);
+          }
+
+          return callback(null, response);
+        });
+      });
+    });
+  }
+};

+ 27 - 0
lib/middlewares.js

@@ -0,0 +1,27 @@
+exports.adminRequired = function() {
+  return function(req, res, next) {
+    if (req.user && '_id' in req.user) {
+      if (req.user.admin) {
+        next();
+        return;
+      }
+      return res.redirect('/');
+    }
+    return res.redirect('/login');
+  };
+};
+
+exports.loginRequired = function() {
+  return function(req, res, next) {
+    if (req.user && '_id' in req.user) {
+      // TODO 移行おわったら削除
+      if (req.user.email && !req.user.password && req.route.path != '/me/password') {
+        return res.redirect('/me/password');
+      }
+
+      return next();
+    }
+    req.session.jumpTo = req.originalUrl;
+    return res.redirect('/login');
+  };
+};

+ 55 - 0
lib/swig_functions.js

@@ -0,0 +1,55 @@
+module.exports = function(app) {
+  var debug = require('debug')('crowi:lib:swig_functions')
+    , models = app.set('models')
+    , Page = models.Page
+    , User = models.User
+  ;
+
+  return {
+    user_page_root: function(user) {
+      if (!user) {
+        return '';
+      }
+      return '/user/' + user.username;
+    },
+    css: {
+      grant: function (pageData) {
+        if (!pageData) {
+          return '';
+        }
+
+        switch (pageData.grant) {
+          case Page.GRANT_PUBLIC:
+            return 'grant-public';
+          case Page.GRANT_RESTRICTED:
+            return 'grant-restricted';
+          //case Page.GRANT_SPECIFIED:
+          //  return 'grant-specified';
+          //  break;
+          case Page.GRANT_OWNER:
+            return 'grant-owner';
+          default:
+            break;
+        }
+        return '';
+      },
+      userStatus: function (user) {
+        //debug('userStatus', user._id, user.usename, user.status);
+
+        switch (user.status) {
+          case User.STATUS_REGISTERED:
+            return 'label-info';
+          case User.STATUS_ACTIVE:
+            return 'label-success';
+          case User.STATUS_SUSPENDED:
+            return 'label-warning';
+          case User.STATUS_DELETED:
+            return 'label-danger';
+          default:
+            break;
+        }
+        return '';
+      },
+    }
+  };
+};

+ 58 - 0
models/bookmark.js

@@ -0,0 +1,58 @@
+module.exports = function(models) {
+  var mongoose = require('mongoose')
+    , debug = require('debug')('crowi:models:bookmark')
+    , ObjectId = mongoose.Schema.Types.ObjectId
+    , bookmarkSchema;
+
+  bookmarkSchema = new mongoose.Schema({
+    page: { type: ObjectId, ref: 'Page', index: true },
+    user: { type: ObjectId, ref: 'User', index: true },
+    createdAt: { type: Date, default: Date.now() }
+  });
+
+
+  // bookmark チェック用
+  bookmarkSchema.statics.findByPageIdAndUser = function(pageId, user, callback) {
+    var Bookmark = this;
+
+    Bookmark.findOne({ page: pageId, user: user._id }, callback);
+  };
+
+  bookmarkSchema.statics.findByUser = function(user, option, callback) {
+    var Bookmark = this;
+
+    var limit = option.limit || 50;
+    var offset = option.skip || 0;
+
+    Bookmark
+      .find({ user: user._id })
+      //.sort('createdAt', -1)
+      .skip(offset)
+      .limit(limit)
+      .exec(function(err, bookmarks) {
+        debug ('bookmarks', bookmarks);
+        callback(err, bookmarks);
+      });
+  };
+
+  bookmarkSchema.statics.add = function(page, user, callback) {
+    var Bookmark = this;
+
+    Bookmark.findOneAndUpdate(
+      { page: page._id, user: user._id },
+      { page: page._id, user: user._id, createdAt: Date.now() },
+      { upsert: true, },
+      function (err, bookmark) {
+        debug('Bookmark.findOneAndUpdate', err, bookmark);
+        callback(err, bookmark);
+    });
+  };
+
+  bookmarkSchema.statics.remove = function(page, user, callback) {
+    // To be implemented ...
+  };
+
+  models.Bookmark = mongoose.model('Bookmark', bookmarkSchema);
+
+  return models.Bookmark;
+};

+ 12 - 0
models/index.js

@@ -0,0 +1,12 @@
+module.exports = function(app) {
+  var models = {};
+
+  require('./page')(models);
+  require('./user')(models);
+  require('./revision')(models);
+  require('./bookmark')(models);
+
+  app.set('models', models);
+
+  return models;
+};

+ 416 - 0
models/page.js

@@ -0,0 +1,416 @@
+module.exports = function(models) {
+  var mongoose = require('mongoose')
+    , debug = require('debug')('crowi:models:page')
+    , ObjectId = mongoose.Schema.Types.ObjectId
+    , GRANT_PUBLIC = 1
+    , GRANT_RESTRICTED = 2
+    , GRANT_SPECIFIED = 3
+    , GRANT_OWNER = 4
+    , PAGE_GRANT_ERROR = 1
+    , pageSchema;
+
+  function populatePageData(pageData, revisionId, callback) {
+    debug('pageData', pageData.revision);
+    if (revisionId) {
+      pageData.revision = revisionId;
+    }
+
+    pageData.latestRevision = pageData.revision;
+    pageData.populate([
+      {path: 'revision', model: 'Revision'},
+      {path: 'liker', options: { limit: 11 }},
+      {path: 'seenUsers', options: { limit: 11 }},
+    ], function (err, pageData) {
+      models.Page.populate(pageData, {path: 'revision.author', model: 'User'}, function(err, pageData) {
+        return callback(err, pageData);
+      });
+    });
+  }
+
+  pageSchema = new mongoose.Schema({
+    path: { type: String, required: true },
+    revision: { type: ObjectId, ref: 'Revision' },
+    redirectTo: String,
+    grant: { type: Number, default: GRANT_PUBLIC },
+    grantedUsers: [{ type: ObjectId, ref: 'User' }],
+    liker: [{ type: ObjectId, ref: 'User' }],
+    seenUsers: [{ type: ObjectId, ref: 'User' }],
+    createdAt: { type: Date, default: Date.now },
+    updatedAt: Date
+  });
+
+  pageSchema.methods.isPublic = function() {
+    if (!this.grant || this.grant == GRANT_PUBLIC) {
+      return true;
+    }
+
+    return false;
+  };
+
+  pageSchema.methods.isGrantedFor = function(userData) {
+    if (this.isPublic()) {
+      return true;
+    }
+
+    if (this.grantedUsers.indexOf(userData._id) >= 0) {
+      return true;
+    }
+
+    return false;
+  };
+
+  pageSchema.methods.isLatestRevision = function() {
+    // populate されていなくて判断できない
+    if (!this.latestRevision || !this.revision) {
+      return true;
+    }
+
+    return (this.latestRevision == this.revision._id.toString());
+  };
+
+  pageSchema.methods.isUpdatable = function(previousRevision) {
+    var revision = this.latestRevision || this.revision;
+    if (revision != previousRevision) {
+      return false;
+    }
+    return true;
+  };
+
+  pageSchema.methods.isLiked = function(userData) {
+    if (undefined === this.populated('liker')) {
+      if (this.liker.indexOf(userData._id) != -1) {
+        return true;
+      }
+      return true;
+    } else {
+      return this.liker.some(function(likedUser) {
+        return likedUser._id.toString() == userData._id.toString();
+      });
+    }
+  };
+
+  pageSchema.methods.like = function(userData, callback) {
+    var self = this;
+    if (undefined === this.populated('liker')) {
+      var added = this.liker.addToSet(userData._id);
+      if (added.length > 0) {
+        this.save(function(err, data) {
+          debug('liker updated!', added);
+          return callback(err, data);
+        });
+      } else {
+        debug('liker not updated');
+        return callback(null, this);
+      }
+    } else {
+      models.Page.update(
+        {_id: self._id},
+        { $addToSet: { liker:  userData._id }},
+        function(err, numAffected, raw) {
+          debug('Updated liker,', err, numAffected, raw);
+          callback(null, self);
+        }
+      );
+    }
+  };
+
+  pageSchema.methods.unlike = function(userData, callback) {
+    var self = this;
+    if (undefined === this.populated('liker')) {
+      var removed = this.liker.pull(userData._id);
+      if (removed.length > 0) {
+        this.save(function(err, data) {
+          debug('unlike updated!', removed);
+          return callback(err, data);
+        });
+      } else {
+        debug('unlike not updated');
+        callback(null, this);
+      }
+    } else {
+      models.Page.update(
+        {_id: self._id},
+        { $pull: { liker:  userData._id }},
+        function(err, numAffected, raw) {
+          debug('Updated liker (unlike)', err, numAffected, raw);
+          callback(null, self);
+        }
+      );
+    }
+  };
+
+  pageSchema.methods.seen = function(userData, callback) {
+    var self = this;
+    if (undefined === this.populated('seenUsers')) {
+      var added = this.seenUsers.addToSet(userData._id);
+      if (added.length > 0) {
+        this.save(function(err, data) {
+          debug('seenUsers updated!', added);
+          return callback(err, data);
+        });
+      } else {
+        debug('seenUsers not updated');
+        return callback(null, this);
+      }
+    } else {
+      models.Page.update(
+        {_id: self._id},
+        { $addToSet: { seenUsers:  userData._id }},
+        function(err, numAffected, raw) {
+          debug('Updated seenUsers,', err, numAffected, raw);
+          callback(null, self);
+        }
+      );
+    }
+  };
+
+  pageSchema.statics.getGrantLabels = function() {
+    var grantLabels = {};
+    grantLabels[GRANT_PUBLIC]     = '公開';
+    grantLabels[GRANT_RESTRICTED] = 'リンクを知っている人のみ';
+    //grantLabels[GRANT_SPECIFIED]  = '特定ユーザーのみ';
+    grantLabels[GRANT_OWNER]      = '自分のみ';
+
+    return grantLabels;
+  };
+
+  pageSchema.statics.normalizePath = function(path) {
+    if (!path.match(/^\//)) {
+      path = '/' + path;
+    }
+
+    return path;
+  };
+
+  pageSchema.statics.isCreatableName = function(name) {
+    var forbiddenPages = [
+      /\^|\$|\*|\+/,
+      /^\/_api\/.*/,
+      /^\/\-\/.*/,
+      /^\/_r\/.*/,
+      /.+\/edit$/,
+      /^\/(register|login|logout|admin|me|files|trash|paste|comments)/,
+    ];
+
+    forbiddenPages.forEach(function(pi) {
+      var page = forbiddenPages[pi];
+      if (name.match(page)) {
+        return false;
+      }
+    });
+
+    return true;
+  };
+
+  pageSchema.statics.updateRevision = function(pageId, revisionId, cb) {
+    this.update({_id: pageId}, {revision: revisionId}, {}, function(err, data) {
+      cb(err, data);
+    });
+  };
+
+  pageSchema.statics.findUpdatedList = function(offset, limit, cb) {
+    this
+      .find({})
+      .sort('updatedAt', -1)
+      .skip(offset)
+      .limit(limit)
+      .exec(function(err, data) {
+        cb(err, data);
+      });
+  };
+
+  pageSchema.statics.findPageById = function(id, userData, cb) {
+    var Page = this;
+
+    this.findOne({_id: id}, function(err, pageData) {
+      if (pageData === null) {
+        return cb(new Error('Page Not Found'), null);
+      }
+
+      if (!pageData.isGrantedFor(userData)) {
+        return cb(PAGE_GRANT_ERROR, null);
+      }
+
+      return populatePageData(pageData, null, cb);
+    });
+  };
+
+  pageSchema.statics.findPage = function(path, userData, revisionId, options, cb) {
+    var Page = this;
+
+    this.findOne({path: path}, function(err, pageData) {
+      if (pageData === null) {
+        return cb(new Error('Page Not Found'), null);
+      }
+
+      if (!pageData.isGrantedFor(userData)) {
+        return cb(PAGE_GRANT_ERROR, null);
+      }
+
+      return populatePageData(pageData, revisionId, cb);
+    });
+  };
+
+  pageSchema.statics.findListByPageIds = function(ids, options, cb) {
+  };
+
+  pageSchema.statics.findListByStartWith = function(path, userData, options, cb) {
+    if (!options) {
+      options = {sort: 'updatedAt', desc: -1, offset: 0, limit: 50};
+    }
+    var opt = {
+      sort: options.sort || 'updatedAt',
+      desc: options.desc || -1,
+      offset: options.offset || 0,
+      limit: options.limit || 50
+    };
+    var sortOpt = {};
+    sortOpt[opt.sort] = opt.desc;
+    var queryReg = new RegExp('^' + path);
+    var sliceOption = options.revisionSlice || {$slice: 1};
+
+    var q = this.find({
+        path: queryReg,
+        redirectTo: null,
+        $or: [
+          {grant: null},
+          {grant: GRANT_PUBLIC},
+          {grant: GRANT_RESTRICTED, grantedUsers: userData._id},
+          {grant: GRANT_SPECIFIED, grantedUsers: userData._id},
+          {grant: GRANT_OWNER, grantedUsers: userData._id},
+        ],
+      })
+      .populate('revision')
+      .sort(sortOpt)
+      .skip(opt.offset)
+      .limit(opt.limit);
+
+    q.exec(function(err, data) {
+      cb(err, data);
+    });
+  };
+
+  pageSchema.statics.updatePage = function(page, updateData, cb) {
+    // TODO foreach して save
+    this.update({_id: page._id}, {$set: updateData}, function(err, data) {
+      return cb(err, data);
+    });
+  };
+
+  pageSchema.statics.updateGrant = function(page, grant, userData, cb) {
+    this.update({_id: page._id}, {$set: {grant: grant}}, function(err, data) {
+      if (grant == GRANT_PUBLIC) {
+        page.grantedUsers = [];
+      } else {
+        page.grantedUsers = [];
+        page.grantedUsers.push(userData._id);
+      }
+      page.save(function(err, data) {
+        return cb(err, data);
+      });
+    });
+  };
+
+  // Instance method でいいのでは
+  pageSchema.statics.pushToGrantedUsers = function(page, userData, cb) {
+    if (!page.grantedUsers || !Array.isArray(page.grantedUsers)) {
+      page.grantedUsers = [];
+    }
+    page.grantedUsers.push(userData._id);
+    page.save(function(err, data) {
+      return cb(err, data);
+    });
+  };
+
+  pageSchema.statics.pushRevision = function(pageData, newRevision, user, cb) {
+    pageData.revision = newRevision._id;
+    pageData.updatedAt = Date.now();
+
+    newRevision.save(function(err, newRevision) {
+      pageData.save(function(err, data) {
+        if (err) {
+          console.log('Error on save page data', err);
+          cb(err, null);
+          return;
+        }
+        cb(err, data);
+      });
+    });
+  };
+
+  pageSchema.statics.create = function(path, body, user, options, cb) {
+    var Page = this
+      , format = options.format || 'markdown'
+      , redirectTo = options.redirectTo || null;
+
+    this.findOne({path: path}, function(err, pageData) {
+      if (pageData) {
+        cb(new Error('Cannot create new page to existed path'), null);
+        return;
+      }
+
+      var newPage = new Page();
+      newPage.path = path;
+      newPage.createdAt = Date.now();
+      newPage.updatedAt = Date.now();
+      newPage.redirectTo = redirectTo;
+      newPage.save(function (err, newPage) {
+
+        var newRevision = models.Revision.prepareRevision(newPage, body, user, {format: format});
+        Page.pushRevision(newPage, newRevision, user, function(err, data) {
+          if (err) {
+            console.log('Push Revision Error on create page', err);
+          }
+          cb(err, data);
+          return;
+        });
+      });
+    });
+  };
+
+  pageSchema.statics.rename = function(pageData, newPageName, user, options, cb) {
+    var Page = this
+      , path = pageData.path
+      , createRedirectPage = options.createRedirectPage || 0
+      , moveUnderTrees     = options.moveUnderTrees || 0;
+
+    // pageData の path を変更
+    this.updatePage(pageData, {updatedAt: Date.now(), path: newPageName}, function(err, data) {
+      if (err) {
+        return cb(err, null);
+      }
+
+      // reivisions の path を変更
+      models.Revision.updateRevisionListByPath(path, {path: newPageName}, {}, function(err, data) {
+        if (err) {
+          return cb(err, null);
+        }
+
+        pageData.path = newPageName;
+        if (createRedirectPage) {
+          Page.create(path, 'redirect ' + newPageName, user, {redirectTo: newPageName}, function(err, data) {
+            // @TODO error handling
+            return cb(err, pageData);
+          });
+        } else {
+          return cb(err, pageData);
+        }
+      });
+    });
+  };
+
+  pageSchema.statics.getHistories = function() {
+    // TODO
+    return;
+  };
+
+  pageSchema.statics.GRANT_PUBLIC = GRANT_PUBLIC;
+  pageSchema.statics.GRANT_RESTRICTED = GRANT_RESTRICTED;
+  pageSchema.statics.GRANT_SPECIFIED = GRANT_SPECIFIED;
+  pageSchema.statics.GRANT_OWNER = GRANT_OWNER;
+  pageSchema.statics.PAGE_GRANT_ERROR = PAGE_GRANT_ERROR;
+
+  models.Page = mongoose.model('Page', pageSchema);
+
+  return models.Page;
+};

+ 74 - 0
models/revision.js

@@ -0,0 +1,74 @@
+module.exports = function(models) {
+  var mongoose = require('mongoose')
+    , ObjectId = mongoose.Schema.Types.ObjectId
+    , revisionSchema;
+
+  revisionSchema = new mongoose.Schema({
+    path: { type: String, required: true },
+    body: { type: String, required: true },
+    format: { type: String, default: 'markdown' },
+    author: { type: ObjectId, ref: 'User' },
+    createdAt: { type: Date, default: Date.now }
+  });
+
+  revisionSchema.statics.findLatestRevision = function(path, cb) {
+    this.find({path: path})
+      .sort({'createdAt': -1})
+      .limit(1)
+      .exec(function(err, data) {
+        cb(err, data.shift());
+      });
+  };
+
+  revisionSchema.statics.findRevisionList = function(path, options, cb) {
+    this.find({path: path})
+      .sort({'createdAt': -1})
+      .populate('author')
+      .exec(function(err, data) {
+        cb(err, data);
+      });
+  };
+
+  revisionSchema.statics.updateRevisionListByPath = function(path, updateData, options, cb) {
+    this.update({path: path}, {$set: updateData}, {multi: true}, function(err, data) {
+      cb(err, data);
+    });
+  };
+
+  revisionSchema.statics.findRevision = function(id, cb) {
+    this.findById(id)
+      .populate('author')
+      .exec(function(err, data) {
+        cb(err, data);
+      });
+  };
+
+  revisionSchema.statics.prepareRevision = function(pageData, body, user, options) {
+    var Revision = this;
+
+    if (!options) {
+      options = {};
+    }
+    var format = options.format || 'markdown';
+
+    if (!user._id) {
+      throw new Error('Error: user should have _id');
+    }
+
+    var newRevision = new Revision();
+    newRevision.path = pageData.path;
+    newRevision.body = body;
+    newRevision.format = format;
+    newRevision.author = user._id;
+    newRevision.createdAt = Date.now();
+
+    return newRevision;
+  };
+
+  revisionSchema.statics.updatePath = function(pathName) {
+  };
+
+  models.Revision = mongoose.model('Revision', revisionSchema);
+
+  return models.Revision;
+};

+ 332 - 0
models/user.js

@@ -0,0 +1,332 @@
+module.exports = function(models) {
+  var mongoose = require('mongoose')
+    , mongoosePaginate = require('mongoose-paginate')
+    , debug = require('debug')('crowi:models:user')
+    , crypto = require('crypto')
+    , config = require('config')
+    , ObjectId = mongoose.Schema.Types.ObjectId
+
+    , STATUS_REGISTERED = 1
+    , STATUS_ACTIVE     = 2
+    , STATUS_SUSPENDED  = 3
+    , STATUS_DELETED    = 4
+
+    , PAGE_ITEMS        = 20
+
+    , userSchema;
+
+  userSchema = new mongoose.Schema({
+    userId: String,
+    fbId: String, // userId
+    image: String,
+    googleId: String,
+    name: { type: String, required: true },
+    username: { type: String, required: true },
+    email: { type: String, required: true },
+    password: String,
+    status: { type: Number, required: true, default: STATUS_ACTIVE },
+    createdAt: { type: Date, default: Date.now },
+    admin: { type: Boolean, default: 0 }
+  });
+  userSchema.plugin(mongoosePaginate);
+
+  function decideUserStatusOnRegistration () {
+    // status decided depends on registrationMode
+    switch (config.security.registrationMode) {
+      case 'Open':
+        return STATUS_ACTIVE;
+      case 'Restricted':
+        return STATUS_REGISTERED;
+      default:
+        return STATUS_REGISTERED; // どっちにすんのがいいんだろうな
+    }
+  }
+
+  function generatePassword (password) {
+    var hasher = crypto.createHash('sha256');
+    hasher.update(config.security.passwordSeed + password);
+
+    return hasher.digest('hex');
+  }
+
+  userSchema.methods.isPasswordSet = function() {
+    if (this.password) {
+      return true;
+    }
+    return false;
+  };
+
+  userSchema.methods.isPasswordValid = function(password) {
+    return this.password == generatePassword(password);
+  };
+
+  userSchema.methods.setPassword = function(password) {
+    return this.password == generatePassword(password);
+  };
+
+  userSchema.methods.setPassword = function(password) {
+    this.password = generatePassword(password);
+    return this;
+  };
+
+  userSchema.methods.isEmailSet = function() {
+    if (this.email) {
+      return true;
+    }
+    return false;
+  };
+
+  userSchema.methods.update = function(name, email, callback) {
+    this.name = name;
+    this.email = email;
+    this.save(function(err, userData) {
+      return callback(err, userData);
+    });
+  };
+
+  userSchema.methods.updatePassword = function(password, callback) {
+    this.setPassword(password);
+    this.save(function(err, userData) {
+      return callback(err, userData);
+    });
+  };
+
+  userSchema.methods.updateImage = function(image, callback) {
+    this.image = image;
+    this.save(function(err, userData) {
+      return callback(err, userData);
+    });
+  };
+
+  userSchema.methods.deleteImage = function(callback) {
+    return this.updateImage(null, callback);
+  };
+
+  userSchema.methods.updateFacebookId = function(fbId, callback) {
+    this.fbId = this.userId = fbId;
+    this.save(function(err, userData) {
+      return callback(err, userData);
+    });
+  };
+
+  userSchema.methods.deleteFacebookId = function(callback) {
+    return this.updateFacebookId(null, callback);
+  };
+
+  userSchema.methods.updateGoogleId = function(googleId, callback) {
+    this.googleId = googleId;
+    this.save(function(err, userData) {
+      return callback(err, userData);
+    });
+  };
+
+  userSchema.methods.deleteGoogleId = function(callback) {
+    return this.updateGoogleId(null, callback);
+  };
+
+
+  userSchema.methods.removeFromAdmin = function(callback) {
+    debug('Remove from admin', this);
+    this.admin = 0;
+    this.save(function(err, userData) {
+      return callback(err, userData);
+    });
+  };
+
+  userSchema.methods.makeAdmin = function(callback) {
+    debug('Admin', this);
+    this.admin = 1;
+    this.save(function(err, userData) {
+      return callback(err, userData);
+    });
+  };
+
+  userSchema.methods.statusActivate = function(callback) {
+    debug('Activate User', this);
+    this.status = STATUS_ACTIVE;
+    this.save(function(err, userData) {
+      return callback(err, userData);
+    });
+  };
+
+  userSchema.methods.statusSuspend = function(callback) {
+    debug('Suspend User', this);
+    this.status = STATUS_SUSPENDED;
+    if (this.email === undefined || this.email === null) { // migrate old data
+      this.email = '-';
+    }
+    if (this.name === undefined || this.name === null) { // migrate old data
+      this.name = '-' + Date.now();
+    }
+    if (this.username === undefined || this.usename === null) { // migrate old data
+      this.username = '-';
+    }
+    this.save(function(err, userData) {
+      return callback(err, userData);
+    });
+  };
+
+  userSchema.methods.statusDelete = function(callback) {
+    debug('Delete User', this);
+    this.status = STATUS_DELETED;
+    this.password = '';
+    this.email = 'deleted@deleted';
+    this.googleId = null;
+    this.fbId = null;
+    this.image = null;
+    this.save(function(err, userData) {
+      return callback(err, userData);
+    });
+  };
+
+  userSchema.methods.updateGoogleIdAndFacebookId = function(googleId, facebookId, callback) {
+    this.googleId = googleId;
+    this.fbId = this.userId = facebookId;
+    this.save(function(err, userData) {
+      return callback(err, userData);
+    });
+  };
+
+  userSchema.statics.getUserStatusLabels = function() {
+    var userStatus = {};
+    userStatus[STATUS_REGISTERED] = '承認待ち';
+    userStatus[STATUS_ACTIVE] = 'Active';
+    userStatus[STATUS_SUSPENDED] = 'Suspended';
+    userStatus[STATUS_DELETED] = 'Deleted';
+
+    return userStatus;
+  };
+
+  userSchema.statics.isEmailValid = function(email, callback) {
+    return config.security.registrationWhiteList.some(function(allowedEmail) {
+      var re = new RegExp(allowedEmail + '$');
+
+      return re.test(email);
+    });
+  };
+
+  userSchema.statics.findUsers = function(options, callback) {
+    var sort = options.sort || {status: 1, createdAt: 1};
+    this.find()
+      .sort(sort)
+      .skip(options.skip || 0)
+      .limit(options.limit || 21)
+      .exec(function (err, userData) {
+        callback(err, userData);
+      });
+
+  };
+
+  userSchema.statics.findUsersWithPagination = function(options, callback) {
+    var sort = options.sort || {status: 1, username: 1, createdAt: 1};
+
+    this.paginate({}, options.page || 1, PAGE_ITEMS, function(err, pageCount, paginatedResults, itemCount) {
+      if (err) {
+        debug('Error on pagination:', err);
+        return callback(err, null);
+      }
+
+      return callback(err, paginatedResults, pageCount, itemCount);
+    }, { sortBy : sort });
+  };
+
+  userSchema.statics.findUserByUsername = function(username, callback) {
+    this.findOne({username: username}, function (err, userData) {
+      callback(err, userData);
+    });
+  };
+
+  userSchema.statics.findUserByFacebookId = function(fbId, callback) {
+    this.findOne({userId: fbId}, function (err, userData) {
+      callback(err, userData);
+    });
+  };
+
+  userSchema.statics.findUserByGoogleId = function(googleId, callback) {
+    this.findOne({googleId: googleId}, function (err, userData) {
+      callback(err, userData);
+    });
+  };
+
+  userSchema.statics.findUserByEmailAndPassword = function(email, password, callback) {
+    var hashedPassword = generatePassword(password);
+    this.findOne({email: email, password: hashedPassword}, function (err, userData) {
+      callback(err, userData);
+    });
+  };
+
+  userSchema.statics.isRegisterable = function(email, username, callback) {
+    var User = this;
+    var emailUsable = true;
+    var usernameUsable = true;
+
+    // username check
+    this.findOne({username: username}, function (err, userData) {
+      if (userData) {
+        usernameUsable = false;
+      }
+
+      // email check
+      User.findOne({email: email}, function (err, userData) {
+        if (userData) {
+          emailUsable = false;
+        }
+
+        if (!emailUsable || !usernameUsable) {
+          return callback(false, {email: emailUsable, username: usernameUsable});
+        }
+
+        return callback(true, {});
+      });
+    });
+  };
+
+  userSchema.statics.createUserByEmailAndPassword = function(name, username, email, password, callback) {
+    var User = this
+      , newUser = new User();
+
+    newUser.name = name;
+    newUser.username = username;
+    newUser.email = email;
+    newUser.setPassword(password);
+    newUser.createdAt = Date.now();
+    newUser.status = decideUserStatusOnRegistration();
+
+    newUser.save(function(err, userData) {
+      return callback(err, userData);
+    });
+  };
+
+  userSchema.statics.createUserByFacebook = function(fbUserInfo, callback) {
+    var User = this
+      , newUser = new User();
+
+    newUser.userId = fbUserInfo.id;
+    newUser.image = '//graph.facebook.com/' + fbUserInfo.id + '/picture?size=square';
+    newUser.name = fbUserInfo.name || '';
+    newUser.username = fbUserInfo.username || '';
+    newUser.email = fbUserInfo.email || '';
+    newUser.createdAt = Date.now();
+    newUser.status = decideUserStatusOnRegistration();
+
+    newUser.save(function(err, userData) {
+      return callback(err, userData);
+    });
+  };
+
+  userSchema.statics.createUserPictureFilePath = function(user, name) {
+    var ext = '.' + name.match(/(.*)(?:\.([^.]+$))/)[2];
+
+    return 'user/' + user._id + ext;
+  };
+
+
+  userSchema.statics.STATUS_REGISTERED = STATUS_REGISTERED;
+  userSchema.statics.STATUS_ACTIVE = STATUS_ACTIVE;
+  userSchema.statics.STATUS_SUSPENDED = STATUS_SUSPENDED;
+  userSchema.statics.STATUS_DELETED = STATUS_DELETED;
+
+  models.User = mongoose.model('User', userSchema);
+
+  return models.User;
+};

+ 61 - 0
package.json

@@ -0,0 +1,61 @@
+{
+  "name": "crowi",
+  "version": "1.0.3",
+  "description": "Crowi - The Simple & Powerful Communication Tool Based on Wiki",
+  "tags": [
+    "wiki"
+  ],
+  "author": "Sotaro KARASAWA <sotarok@crocos.co.jp>",
+  "contributors": [
+    "Keisuke SATO <riaf@me.com> (http://riaf.jp)"
+  ],
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/crowi/crowi.git"
+  },
+  "engines": {
+    "node": "0.10.x",
+    "npm": "1.3.x"
+  },
+  "dependencies": {
+    "async": "=0.1.18",
+    "aws-sdk": "~2.0.0-rc.19",
+    "config": "=0.4.9",
+    "connect-flash": "~0.1.1",
+    "consolidate": "=0.10.0",
+    "debug": "^1.0.3",
+    "express": "=3.4.4",
+    "express-form": "~0.10.1",
+    "facebook-node-sdk": "=0.1.10",
+    "googleapis": "=0.4.7",
+    "jquery.cookie": "~1.4.1",
+    "marked": "=0.2.9",
+    "mongoose": "=3.8.14",
+    "socket.io": "~0.9.16",
+    "socket.io-client": "~0.9.16",
+    "swig": "=1.3.2",
+    "time": "=0.10.0",
+    "mongoose-paginate": "~3.1.0"
+  },
+  "devDependencies": {
+    "grunt": "~0.4.1",
+    "grunt-contrib-concat": "~0.3.0",
+    "grunt-contrib-jshint": "^0.10.0",
+    "grunt-contrib-uglify": "~0.2.2",
+    "grunt-contrib-watch": "~0.5.3",
+    "grunt-sass": "~0.13.1",
+    "reveal.js": "~2.6.2"
+  },
+  "license": [
+    {
+      "type": "MIT",
+      "url": "http://www.opensource.org/licenses/MIT"
+    }
+  ],
+  "scripts": {
+    "test": "//"
+  },
+  "bugs": {
+    "url": "https://github.com/crowi/crowi/issues"
+  }
+}

BIN
public/apple-touch-icon-114x114-precomposed.png


BIN
public/apple-touch-icon-57x57-precomposed.png


BIN
public/apple-touch-icon-72x72-precomposed.png


BIN
public/apple-touch-icon-precomposed.png


BIN
public/apple-touch-icon.png


+ 1 - 0
public/css/.gitignore

@@ -0,0 +1 @@
+

BIN
public/favicon.ico


+ 1 - 0
public/fonts

@@ -0,0 +1 @@
+../bower_components/fontawesome/fonts

+ 3 - 0
public/hoge.css

@@ -0,0 +1,3 @@
+body {
+  font-family: Helvetica;
+}

BIN
public/images/loading_l.gif


BIN
public/images/loading_s.gif


BIN
public/images/userpicture.png


+ 1 - 0
public/js/.gitignore

@@ -0,0 +1 @@
+

+ 82 - 0
public/nginx-mime.types

@@ -0,0 +1,82 @@
+# this file is used by the nginx.conf
+
+types {
+    text/html                             html htm shtml;
+    text/css                              css;
+    text/xml                              xml rss;
+    image/gif                             gif;
+    image/jpeg                            jpeg jpg;
+    application/javascript                js;
+    application/atom+xml                  atom;
+
+    text/cache-manifest                   manifest appcache;
+    text/mathml                           mml;
+    text/plain                            txt;
+    text/vnd.sun.j2me.app-descriptor      jad;
+    text/vnd.wap.wml                      wml;
+    text/x-component                      htc;
+
+    image/png                             png;
+    image/svg+xml                         svg svgz;
+    image/tiff                            tif tiff;
+    image/vnd.wap.wbmp                    wbmp;
+    image/webp                            webp;
+    image/x-icon                          ico;
+    image/x-jng                           jng;
+    image/x-ms-bmp                        bmp;
+
+    application/java-archive              jar war ear;
+    application/mac-binhex40              hqx;
+    application/msword                    doc;
+    application/pdf                       pdf;
+    application/postscript                ps eps ai;
+    application/rtf                       rtf;
+    application/vnd.ms-excel              xls;
+    application/vnd.ms-powerpoint         ppt;
+    application/vnd.wap.wmlc              wmlc;
+    application/vnd.wap.xhtml+xml         xhtml;
+    application/x-chrome-extension        crx;
+    application/x-cocoa                   cco;
+    application/x-java-archive-diff       jardiff;
+    application/x-java-jnlp-file          jnlp;
+    application/x-makeself                run;
+    application/x-perl                    pl pm;
+    application/x-pilot                   prc pdb;
+    application/x-rar-compressed          rar;
+    application/x-redhat-package-manager  rpm;
+    application/x-sea                     sea;
+    application/x-shockwave-flash         swf;
+    application/x-stuffit                 sit;
+    application/x-tcl                     tcl tk;
+    application/x-x509-ca-cert            der pem crt;
+    application/x-xpinstall               xpi;
+    application/zip                       zip;
+
+    application/octet-stream              bin exe dll;
+    application/octet-stream              deb;
+    application/octet-stream              dmg;
+    application/octet-stream              iso img;
+    application/octet-stream              msi msp msm;
+    application/octet-stream              safariextz
+
+    audio/midi                            mid midi kar;
+    audio/mpeg                            mp3;
+    audio/x-realaudio                     ra;
+    audio/ogg                             oga ogg;
+
+    video/3gpp                            3gpp 3gp;
+    video/mpeg                            mpeg mpg;
+    video/ogg                             ogv;
+    video/quicktime                       mov;
+    video/webm                            webm;
+    video/x-flv                           flv;
+    video/x-mng                           mng;
+    video/x-ms-asf                        asx asf;
+    video/x-ms-wmv                        wmv;
+    video/x-msvideo                       avi;
+
+    application/vnd.ms-fontobject         eot;
+    font/truetype                         ttf;
+    font/opentype                         otf;
+    font/woff                             woff;
+}

+ 126 - 0
public/nginx.conf

@@ -0,0 +1,126 @@
+# Set another default user than root for security reasons
+user       www www;
+
+# As a thumb rule: One per CPU. If you are serving a large amount
+# of static files, which requires blocking disk reads, you may want
+# to increase this from the number of cpu_cores available on your
+# system.
+#
+# The maximum number of connections for Nginx is calculated by:
+# max_clients = worker_processes * worker_connections
+worker_processes 1;
+
+# Maximum file descriptors that can be opened per process
+# This should be > worker_connections
+worker_rlimit_nofile 8192;
+
+events {
+  # When you need > 8000 * cpu_cores connections, you start optimizing
+  # your OS, and this is probably the point at where you hire people
+  # who are smarter than you, this is *a lot* of requests.
+  worker_connections  8000;
+}
+
+# Change these paths to somewhere that suits you!
+error_log  logs/error.log;
+pid        logs/nginx.pid;
+
+http {
+  # Set the mime-types via the mime.types external file
+  include       nginx-mime.types;
+
+  # And the fallback mime-type
+  default_type  application/octet-stream;
+
+  # Format for our log files
+  log_format   main '$remote_addr - $remote_user [$time_local]  $status '
+    '"$request" $body_bytes_sent "$http_referer" '
+    '"$http_user_agent" "$http_x_forwarded_for"';
+
+  # Click tracking!
+  access_log   logs/access.log  main;
+
+  # ~2 seconds is often enough for HTML/CSS, but connections in
+  # Nginx are cheap, so generally it's safe to increase it
+  keepalive_timeout 20;
+
+  # You usually want to serve static files with Nginx
+  sendfile on;
+
+  tcp_nopush on; # off may be better for Comet/long-poll stuff
+  tcp_nodelay off; # on may be better for Comet/long-poll stuff
+
+  # Enable Gzip:
+  gzip on;
+  gzip_http_version 1.0;
+  gzip_comp_level 5;
+  gzip_min_length 512;
+  gzip_buffers 4 8k;
+  gzip_proxied any;
+  gzip_types
+    # text/html is always compressed by HttpGzipModule
+    text/css
+    text/javascript
+    text/xml
+    text/plain
+    text/x-component
+    application/javascript
+    application/json
+    application/xml
+    application/rss+xml
+    font/truetype
+    font/opentype
+    application/vnd.ms-fontobject
+    image/svg+xml;
+
+  # This should be turned on if you are going to have pre-compressed copies (.gz) of
+  # static files available. If not it should be left off as it will cause extra I/O
+  # for the check. It would be better to enable this in a location {} block for
+  # a specific directory:
+  # gzip_static on;
+
+  gzip_disable        "MSIE [1-6]\.";
+  gzip_vary           on;
+
+  server {
+    # listen 80 default_server deferred; # for Linux
+    # listen 80 default_server accept_filter=httpready; # for FreeBSD
+    listen 80 default_server;
+
+    # e.g. "localhost" to accept all connections, or "www.example.com"
+    # to handle the requests for "example.com" (and www.example.com)
+    # server_name www.example.com;
+
+    # Path for static files
+    root /sites/example.com/public;
+
+    # Custom 404 page
+    error_page 404 /404.html;
+
+    # This is pretty long expiry and assume your using
+    # cachebusting with query params like
+    #   <script src="application.js?20110529">
+    #
+    # Just be careful if your using this on a frequently
+    # updated static site. You may want to crank this back
+    # to 5m which is 5 minutes.
+    expires 1M; # yes one month
+
+    # Static assets
+    location ~* ^.+\.(manifest|appcache)$ {
+      expires -1;
+      access_log logs/static.log;
+    }
+
+    # Set expires max on static file types (make sure you are using cache busting filenames or query params):
+    location ~* ^.+\.(css|js|jpg|jpeg|gif|png|ico|gz|svg|svgz|ttf|otf|woff|eot|mp4|ogg|ogv|webm)$ {
+      expires max;
+      access_log off;
+    }
+
+    # opt-in to the future
+    add_header "X-UA-Compatible" "IE=Edge,chrome=1";
+
+  }
+}
+

+ 10 - 0
resource/css/_admin.scss

@@ -0,0 +1,10 @@
+.crowi { // {{{
+
+  .admin-user-menu {
+    .dropdown-menu {
+      left: auto;
+      right: 0;
+    }
+  }
+
+} // }}}

+ 26 - 0
resource/css/_form.scss

@@ -0,0 +1,26 @@
+
+.form-full {
+  width: 99%;
+}
+
+textarea {
+  font-family: menlo, monaco, consolas, monospace;
+  line-height: 1.1em;
+}
+
+textarea.form-body-height {
+  height: 300px;
+}
+
+.form-maximized {
+  position: absolute;
+  background: #fff;
+  top: 0;
+  left: 0;
+  margin: 0;
+  padding: 0;
+  width: 100%;
+  height: 100%;
+  z-index: 100;
+  -webkit-transition: opacity 1s linear;
+}

+ 537 - 0
resource/css/_layout.scss

@@ -0,0 +1,537 @@
+.crowi { // {{{
+  font-family: 'Maven Pro', 'Helvetica Neue', 'Hiragino Kaku Gothic Pro', 'Meiryo', sans-serif;
+  h1, h2, h3, h4, h5, h6 {
+    font-weight: 500;
+  }
+
+  &.main-container { // {{{
+
+    .crowi-header { // {{{
+      z-index: 1050;
+      background: $crowiHeaderBackground;
+      height: $crowiHeaderHeight;
+      border-radius: 0;
+      border: none;
+      margin-bottom: 0;
+
+      box-shadow: 0 3px 10px 0px rgba(0,0,0,.3);
+
+      .navbar-brand {
+        font-weight: bold;
+      }
+      .navbar-collapse {
+        background: $crowiHeaderBackground;
+      }
+
+      > div > a ,
+      > div > ul > li > a {
+        color: #ccc;
+        &:hover {
+          color: #aaa;
+        }
+      }
+
+      .confidential {
+        a {
+          border: solid 2px #f00;
+          background: #fff;
+          color: #f00;
+          font-weight: bold;
+          height: 42px;
+          margin-top: 5px;
+          padding: 10px;
+          margin-right: 5px;
+        }
+      }
+
+      .navbar-toggle {
+        position: absolute;
+        top: 0;
+        right: 0;
+      }
+      .navbar-collapse.collapse.in {
+
+        .confidential {
+        }
+      }
+
+      .layout-control.to-show {
+        display: none;
+      }
+    } // }}}
+
+    .layout-control.to-hide { // {{{
+      transition: .3s ease;
+      -webkit-transition: .3s ease;
+      position: fixed;
+      display: block;
+      text-align: center;
+      right: 25%;
+      bottom: 25px;
+      padding: 5px 8px;
+      border: solid 1px #ccc;
+      border-right: none;
+      background: $crowiAsideBackground;
+      border-radius: 5px 0 0 5px;
+      z-index: 1055;
+      font-size: .8em;
+
+      color: darken($link-color, 15%);
+
+      &:hover {
+        color: darken($link-color, 25%);
+        background: darken($crowiAsideBackground, 10%);
+        text-decoration: none;
+      }
+    } // }}}
+
+
+    aside.sidebar { // {{{
+      z-index: 1040;
+      position: fixed;
+      padding: 65px 0 0 0;
+      margin-bottom: $crowiFooterHeight;
+      color: #333;
+      height: 100%;
+      right: 0;
+      top: 0;
+      overflow: auto;
+      border-left: solid 1px #ccc;
+      background: $crowiAsideBackground;
+
+      transition: .3s ease;
+      -webkit-transition: .3s ease;
+
+
+      .page-meta {
+        padding: 15px 15px 5px 15px;
+        color: #666;
+        font-size: .9em;
+        line-height: 1.4em;
+        border-bottom: solid 1px #ccc;
+
+        .creator-picture {
+          text-align: center;
+          img {
+            width: 48px;
+            height: 48px;
+            box-shadow: 0 0 2px #333;
+          }
+        }
+        .creator {
+          font-size: 1.3em;
+          font-weight: bold;
+        }
+        .created-at {
+        }
+
+        .like-box {
+          padding-bottom: 0;
+
+          .dl-horizontal {
+            margin-bottom: 0;
+
+            dt, dd {
+              border-top: solid 1px #ccc;
+              padding-top: 5px;
+              padding-bottom: 5px;
+            }
+            dt {
+              width: 80px;
+            }
+            dd {
+              margin-left: 90px;
+              text-align: right;
+            }
+          }
+
+          .btn-bookmark {
+            color: #e6b422;
+            &.bookmarked {
+              color: #fff;
+            }
+          }
+        }
+
+        .liker-list, .contributor-list, .seen-user-list {
+          .picture-rounded {
+            box-shadow: 0 0 2px #666;
+          }
+        }
+        .liker-count, .contributor-count, .seen-user-count {
+          font-size: 1.2em;
+          font-weight: bold;
+          margin-bottom: 5px;
+        }
+        .contributor-list, .seen-user-list {
+        }
+      }
+
+
+      .side-content {
+        margin-bottom: $crowiFooterHeight + $crowiHeaderHeight;
+        padding: 15px;
+
+        h3 {
+          font-size: 1.1em;
+        }
+
+        a {
+          color: #ccc;
+          &:hover { color: #aaa;}
+        }
+
+        ul.revision-history {
+          padding: 0;
+          li {
+            position: relative;
+            list-style: none;
+
+            a {
+              color: darken($link-color, 50%);
+              padding: 3px 5px 3px 40px;
+              display: block;
+
+              &:hover {
+                background: darken($crowiAsideBackground, 10%);
+                text-decoration: none;
+                color: darken($link-color, 35%);
+              }
+            }
+
+          }
+
+          .picture {
+            position: absolute;
+            left: 5px;
+            top: 12px;
+          }
+        }
+
+        ul.fitted-list {
+          padding-left: 0;
+          li {
+            margin-bottom: 2px;
+
+            .input-group-addon {
+              padding: 5px 6px;
+            }
+          }
+        }
+      }
+    } // }}}
+
+    .main { // {{{
+      transition: .5s ease;
+      -webkit-transition: .5s ease;
+      background: #fff;
+
+      padding: 20px;
+      //margin-left: 10px;
+      //padding: 10px;
+      //
+      article {
+        background: #fff;
+      }
+
+      article header {
+        background: #fff;
+        width: 100%;
+
+        p.stopper {
+          display: none;
+        }
+
+        &.affix {
+          width: 100%;
+          top: 0;
+          left: 0;
+          padding: 5px 20px;
+          z-index: 1041;
+          background: #fff;
+          box-shadow: 0 0px 2px #999;
+
+          transition: .5s ease;
+          -webkit-transition: .5s ease;
+
+          h1 {
+            font-size: 1.8em;
+          }
+
+          p.stopper {
+            display: block;
+            position: absolute;
+            bottom: -30px;
+            right: 10px;
+            background: #fff;
+            padding: 7px 14px;
+            margin: 0;
+            border: solid 1px #ccc;
+            border-top: none;
+            border-radius: 0 0 5px 5px;
+            font-size: .8em;
+          }
+        }
+      }
+      &.col-md-12 article header.affix {
+        width: 100%;
+      }
+
+      article header h1 {
+        margin-top: 0;
+
+        a:last-child {
+          color: #D1E2E4;
+          opacity: .7;
+
+          &:hover {
+            color: inherit;
+          }
+        }
+      }
+
+    } // }}}
+
+    .main.grant-restricted,
+    .main.grant-specified,
+    .main.grant-owner {
+      background: #333;
+      padding: 20px 10px;
+
+      .page-grant {
+        color: #ccc;
+      }
+
+      article {
+        border-radius: 5px;
+        padding: 20px;
+      }
+    }
+
+    .footer { // {{{
+      position: fixed;
+      width: 100%;
+      bottom: 0px;
+      height: 26px;
+      padding: 4px;
+      color: #444;
+      background: $crowiAsideBackground;
+      border-top-left-radius: 5px;
+      z-index: 1055;
+
+      a {
+        color: #666;
+      }
+    } // }}}
+  } // }}}
+
+  &.main-container.aside-hidden { // {{{
+    .crowi-header .layout-control.to-show {
+      display: block;
+    }
+    .layout-control.to-hide {
+      right: 0;
+      i {
+        transform: rotate(180deg);
+      }
+    }
+
+    aside.sidebar { // {{{
+      right: -25%;
+    } // }}}
+
+    .main { // {{{
+      width: 100%;
+
+      article header.affix {
+        width: 100%;
+      }
+    } // }}}
+  } // }}}
+
+  // override bootstrap modals
+  .modal-backdrop {
+    z-index: 1052;
+  }
+  .modal {
+    z-index: 1055;
+  }
+} // }}}
+
+.crowi.main-container .main {
+  .wiki-content {
+  }
+
+  .tab-content {
+    margin-top: 30px;
+    .form-box {
+      margin-top: 30px;
+    }
+  }
+}
+
+.crowi.single { // {{{
+} // }}}
+
+.crowi.single.nologin { // {{{
+  background: lighten($crowiHeaderBackground, 15%);
+
+  .main {
+    margin: 0;
+    padding: 0;
+  }
+
+  h1.login-page {
+    margin-top: 80px;
+    color: #fff;
+    font-size: 1.6em;
+    padding: 10px;
+    text-align: center;
+    text-shadow: 0px 0px 6px rgba(0,0,0,.5);
+    line-height: 100%;
+  }
+
+
+  .login-dialog-container {
+
+    .facebook-info {
+      border-radius: 4px;
+      border: solid 1px #ccc;
+      padding: 10px;
+      margin-bottom: 15px;
+    }
+
+    margin: 40px auto;
+    float: none;
+
+    .login-dialog {
+      position: relative;
+
+      form {
+        margin-top: 10px;
+        .input-group {
+          margin-bottom: 10px;
+        }
+      }
+
+      .login-dialog-inner, .register-dialog-inner {
+        top: 0;
+        left: 0;
+        padding: 42px 48px;
+        background: #fff;
+        position: absolute;
+        margin-bottom: 40px;
+        width: 100%;
+
+        box-shadow: 0 1px 40px 0 rgba(0,0,0,0.3);
+        border-radius: 3px;
+
+        h2 {
+          margin: 0 0 28px;
+          font-size: 1.3em;
+          text-align: center;
+        }
+      }
+
+      p.bottom-text {
+        text-align: right;
+        margin: 20px 0 0;
+      }
+    }
+
+  }
+
+
+} // }}}
+
+
+@media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { // {{{ tablet size
+  .crowi.main-container { // {{{
+    .main {
+      article header {
+        h1 {
+          font-size: 1.4em;
+          margin-bottom: 0;
+        }
+
+        &.affix {
+          width: 100%;
+        }
+      }
+    }
+  }
+} // }}}
+
+@media (max-width: $screen-xs-max) { // {{{ iPhone size
+  .crowi.main-container { // {{{
+    .main {
+      padding: 10px;
+      article header {
+        h1 {
+          font-size: 1.1em;
+        }
+
+        &.affix {
+          h1 {
+            font-size: 1.1em;
+          }
+
+          width: 100%;
+          padding: 5px;
+          box-shadow: 0 0px 2px #999;
+
+          p.stopper {
+            right: 20px;
+          }
+        }
+      }
+    }
+  }
+}
+
+@media print { // {{{ printable style
+  .crowi.main-container { // {{{
+    padding: 30px;
+
+    a:after {
+      display: none !important;
+    }
+    .main {
+      article header {
+        border-bottom: solid 1px #666;
+        margin-bottom: 20px;
+        h1 {
+          font-size: 2em;
+          color: #000;
+        }
+      }
+
+      .revision-toc {
+        float: none;
+        font-size: .9em;
+        border: solid 1px #aaa;
+        border-radius: 5px;
+        max-width: 100%;
+        margin-bottom: 20px;
+
+        .revision-toc-head {
+          display: inline-block;
+          float: none;
+        }
+
+        .revision-toc-content.collapse {
+          display: block;
+          height: auto;
+        }
+      }
+
+      .meta {
+        border-top: solid 1px #999;
+        margin-top: 20px;
+        color: #666;
+      }
+
+
+    }
+  }
+} // }}}

+ 12 - 0
resource/css/_mixins.scss

@@ -0,0 +1,12 @@
+
+// Badget
+@mixin badge-variant($color) {
+  background-color: $color;
+
+  &[href] {
+    &:hover,
+    &:focus {
+      background-color: darken($color, 10%);
+    }
+  }
+}

+ 654 - 0
resource/css/_variables.scss

@@ -0,0 +1,654 @@
+// crowi
+$brand-primary:         #43676b;
+
+$crowiHeaderBackground: darken($brand-primary, 15%);
+$crowiHeaderHeight: 50px;
+
+// $crowiAsideBackground:   darken($crowiHeaderBackground, 5%);
+$crowiAsideBackground: #fcfcfc;
+
+$crowiFooterBackground:  $crowiHeaderBackground;
+$crowiFooterHeight: 34px;
+
+
+
+//
+// Variables
+// --------------------------------------------------
+
+
+// Global values
+// --------------------------------------------------
+
+// Grays
+// -------------------------
+
+$gray-darker:            lighten(#000, 13.5%); // #222
+$gray-dark:              lighten(#000, 20%);   // #333
+// $gray:                   lighten(#000, 33.5%); // #555
+// $gray-light:             lighten(#000, 60%);   // #999
+// $gray-lighter:           lighten(#000, 93.5%); // #eee
+//
+// // Brand colors
+// // -------------------------
+//
+//$brand-primary: #4d4398;
+//$brand-primary: #622d18;
+//$brand-primary: #e5a323;
+// $brand-success:         #5cb85c;
+// $brand-warning:         #f0ad4e;
+// $brand-danger:          #d9534f;
+// $brand-info:            #5bc0de;
+
+//
+// // Scaffolding
+// // -------------------------
+//
+//$body-bg:              $crowiHeaderBackground;
+// $text-color:            $gray-dark;
+//
+// // Links
+// // -------------------------
+//
+$link-color:            $brand-primary;
+$link-hover-color:      darken($link-color, 15%);
+//
+// // Typography
+// // -------------------------
+//
+// $font-family-sans-serif:  "Helvetica Neue", Helvetica, Arial, sans-serif;
+// $font-family-serif:       Georgia, "Times New Roman", Times, serif;
+// $font-family-monospace:   Monaco, Menlo, Consolas, "Courier New", monospace;
+// $font-family-base:        $font-family-sans-serif;
+//
+// $font-size-base:          14px;
+// $font-size-large:         ceil($font-size-base * 1.25); // ~18px
+// $font-size-small:         ceil($font-size-base * 0.85); // ~12px
+//
+// $font-size-h1:            floor($font-size-base * 2.6); // ~36px
+// $font-size-h2:            floor($font-size-base * 2.15); // ~30px
+// $font-size-h3:            ceil($font-size-base * 1.7); // ~24px
+// $font-size-h4:            ceil($font-size-base * 1.25); // ~18px
+// $font-size-h5:            $font-size-base;
+// $font-size-h6:            ceil($font-size-base * 0.85); // ~12px
+//
+// $line-height-base:        1.428571429; // 20/14
+// $line-height-computed:    floor($font-size-base * $line-height-base); // ~20px
+//
+// $headings-font-family:    $font-family-base;
+// $headings-font-weight:    500;
+// $headings-line-height:    1.1;
+// $headings-color:          inherit;
+//
+//
+// // Iconography
+// // -------------------------
+//
+// $icon-font-path:          "../fonts/";
+// $icon-font-name:          "glyphicons-halflings-regular";
+//
+//
+// // Components
+// // -------------------------
+// // Based on 14px font-size and 1.428 line-height (~20px to start)
+//
+// $padding-base-vertical:          6px;
+// $padding-base-horizontal:        12px;
+//
+// $padding-large-vertical:         10px;
+// $padding-large-horizontal:       16px;
+//
+// $padding-small-vertical:         5px;
+// $padding-small-horizontal:       10px;
+//
+// $line-height-large:              1.33;
+// $line-height-small:              1.5;
+//
+// $border-radius-base:             4px;
+// $border-radius-large:            6px;
+// $border-radius-small:            3px;
+//
+// $component-active-color:         #fff;
+// $component-active-bg:            $brand-primary;
+//
+// $caret-width-base:               4px;
+// $caret-width-large:              5px;
+//
+// // Tables
+// // -------------------------
+//
+// $table-cell-padding:                 8px;
+// $table-condensed-cell-padding:       5px;
+//
+// $table-bg:                           transparent; // overall background-color
+// $table-bg-accent:                    #f9f9f9; // for striping
+// $table-bg-hover:                     #f5f5f5;
+// $table-bg-active:                    $table-bg-hover;
+//
+// $table-border-color:                 #ddd; // table and cell border
+//
+//
+// // Buttons
+// // -------------------------
+//
+// $btn-font-weight:                normal;
+//
+// $btn-default-color:              #333;
+// $btn-default-bg:                 #fff;
+// $btn-default-border:             #ccc;
+//
+// $btn-primary-color:              #fff;
+// $btn-primary-bg:                 $brand-primary;
+// $btn-primary-border:             darken($btn-primary-bg, 5%);
+//
+// $btn-success-color:              #fff;
+// $btn-success-bg:                 $brand-success;
+// $btn-success-border:             darken($btn-success-bg, 5%);
+//
+// $btn-warning-color:              #fff;
+// $btn-warning-bg:                 $brand-warning;
+// $btn-warning-border:             darken($btn-warning-bg, 5%);
+//
+// $btn-danger-color:               #fff;
+// $btn-danger-bg:                  $brand-danger;
+// $btn-danger-border:              darken($btn-danger-bg, 5%);
+//
+// $btn-info-color:                 #fff;
+// $btn-info-bg:                    $brand-info;
+// $btn-info-border:                darken($btn-info-bg, 5%);
+//
+// $btn-link-disabled-color:        $gray-light;
+//
+//
+// // Forms
+// // -------------------------
+//
+// $input-bg:                       #fff;
+// $input-bg-disabled:              $gray-lighter;
+//
+// $input-color:                    $gray;
+// $input-border:                   #ccc;
+// $input-border-radius:            $border-radius-base;
+// $input-border-focus:             #66afe9;
+//
+// $input-color-placeholder:        $gray-light;
+//
+// $input-height-base:              ($line-height-computed + ($padding-base-vertical * 2) + 2);
+// $input-height-large:             (floor($font-size-large * $line-height-large) + ($padding-large-vertical * 2) + 2);
+// $input-height-small:             (floor($font-size-small * $line-height-small) + ($padding-small-vertical * 2) + 2);
+//
+// $legend-color:                   $gray-dark;
+// $legend-border-color:            #e5e5e5;
+//
+// $input-group-addon-bg:           $gray-lighter;
+// $input-group-addon-border-color: $input-border;
+//
+//
+// // Dropdowns
+// // -------------------------
+//
+// $dropdown-bg:                    #fff;
+// $dropdown-border:                rgba(0,0,0,.15);
+// $dropdown-fallback-border:       #ccc;
+// $dropdown-divider-bg:            #e5e5e5;
+//
+$dropdown-link-color:            $gray-dark;
+$dropdown-link-hover-color:      darken($gray-dark, 5%);
+$dropdown-link-hover-bg:         #f5f5f5;
+//
+// $dropdown-link-active-color:     $component-active-color;
+// $dropdown-link-active-bg:        $component-active-bg;
+//
+// $dropdown-link-disabled-color:   $gray-light;
+//
+// $dropdown-header-color:          $gray-light;
+//
+// $dropdown-caret-color:           #000;
+//
+//
+// // COMPONENT VARIABLES
+// // --------------------------------------------------
+//
+//
+// // Z-index master list
+// // -------------------------
+// // Used for a bird's eye view of components dependent on the z-axis
+// // Try to avoid customizing these :)
+//
+// $zindex-navbar:            1000;
+// $zindex-dropdown:          1000;
+// $zindex-popover:           1010;
+// $zindex-tooltip:           1030;
+// $zindex-navbar-fixed:      1030;
+// $zindex-modal-background:  1040;
+// $zindex-modal:             1050;
+//
+// // Media queries breakpoints
+// // --------------------------------------------------
+//
+// // Extra small screen / phone
+// // Note: Deprecated $screen-xs and $screen-phone as of v3.0.1
+// $screen-xs:                  480px;
+// $screen-xs-min:              $screen-xs;
+// $screen-phone:               $screen-xs-min;
+//
+// // Small screen / tablet
+// // Note: Deprecated $screen-sm and $screen-tablet as of v3.0.1
+// $screen-sm:                  768px;
+// $screen-sm-min:              $screen-sm;
+// $screen-tablet:              $screen-sm-min;
+//
+// // Medium screen / desktop
+// // Note: Deprecated $screen-md and $screen-desktop as of v3.0.1
+// $screen-md:                  992px;
+// $screen-md-min:              $screen-md;
+// $screen-desktop:             $screen-md-min;
+//
+// // Large screen / wide desktop
+// // Note: Deprecated $screen-lg and $screen-lg-desktop as of v3.0.1
+// $screen-lg:                  1200px;
+// $screen-lg-min:              $screen-lg;
+// $screen-lg-desktop:          $screen-lg-min;
+//
+// // So media queries don't overlap when required, provide a maximum
+// $screen-xs-max:              ($screen-sm-min - 1);
+// $screen-sm-max:              ($screen-md-min - 1);
+// $screen-md-max:              ($screen-lg-min - 1);
+//
+//
+// // Grid system
+// // --------------------------------------------------
+//
+// // Number of columns in the grid system
+// $grid-columns:              12;
+// // Padding, to be divided by two and applied to the left and right of all columns
+// $grid-gutter-width:         30px;
+// // Point at which the navbar stops collapsing
+// $grid-float-breakpoint:     $screen-sm-min;
+//
+//
+// // Navbar
+// // -------------------------
+//
+// // Basics of a navbar
+// $navbar-height:                    50px;
+// $navbar-margin-bottom:             $line-height-computed;
+// $navbar-border-radius:             $border-radius-base;
+// $navbar-padding-horizontal:        floor($grid-gutter-width / 2);
+// $navbar-padding-vertical:          (($navbar-height - $line-height-computed) / 2);
+//
+// $navbar-default-color:             #777;
+// $navbar-default-bg:                #f8f8f8;
+// $navbar-default-border:            darken($navbar-default-bg, 6.5%);
+//
+// // Navbar links
+// $navbar-default-link-color:                #777;
+// $navbar-default-link-hover-color:          #333;
+// $navbar-default-link-hover-bg:             transparent;
+// $navbar-default-link-active-color:         #555;
+// $navbar-default-link-active-bg:            darken($navbar-default-bg, 6.5%);
+// $navbar-default-link-disabled-color:       #ccc;
+// $navbar-default-link-disabled-bg:          transparent;
+//
+// // Navbar brand label
+// $navbar-default-brand-color:               $navbar-default-link-color;
+// $navbar-default-brand-hover-color:         darken($navbar-default-brand-color, 10%);
+// $navbar-default-brand-hover-bg:            transparent;
+//
+// // Navbar toggle
+// $navbar-default-toggle-hover-bg:           #ddd;
+// $navbar-default-toggle-icon-bar-bg:        #ccc;
+// $navbar-default-toggle-border-color:       #ddd;
+//
+//
+// // Inverted navbar
+// //
+// // Reset inverted navbar basics
+// $navbar-inverse-color:                      $gray-light;
+// $navbar-inverse-bg:                         #222;
+// $navbar-inverse-border:                     darken($navbar-inverse-bg, 10%);
+//
+// // Inverted navbar links
+// $navbar-inverse-link-color:                 $gray-light;
+// $navbar-inverse-link-hover-color:           #fff;
+// $navbar-inverse-link-hover-bg:              transparent;
+// $navbar-inverse-link-active-color:          $navbar-inverse-link-hover-color;
+// $navbar-inverse-link-active-bg:             darken($navbar-inverse-bg, 10%);
+// $navbar-inverse-link-disabled-color:        #444;
+// $navbar-inverse-link-disabled-bg:           transparent;
+//
+// // Inverted navbar brand label
+// $navbar-inverse-brand-color:                $navbar-inverse-link-color;
+// $navbar-inverse-brand-hover-color:          #fff;
+// $navbar-inverse-brand-hover-bg:             transparent;
+//
+// // Inverted navbar toggle
+// $navbar-inverse-toggle-hover-bg:            #333;
+// $navbar-inverse-toggle-icon-bar-bg:         #fff;
+// $navbar-inverse-toggle-border-color:        #333;
+//
+//
+// // Navs
+// // -------------------------
+//
+// $nav-link-padding:                          10px 15px;
+// $nav-link-hover-bg:                         $gray-lighter;
+//
+// $nav-disabled-link-color:                   $gray-light;
+// $nav-disabled-link-hover-color:             $gray-light;
+//
+// $nav-open-link-hover-color:                 #fff;
+// $nav-open-caret-border-color:               #fff;
+//
+// // Tabs
+// $nav-tabs-border-color:                     #ddd;
+//
+// $nav-tabs-link-hover-border-color:          $gray-lighter;
+//
+// $nav-tabs-active-link-hover-bg:             $body-bg;
+// $nav-tabs-active-link-hover-color:          $gray;
+// $nav-tabs-active-link-hover-border-color:   #ddd;
+//
+// $nav-tabs-justified-link-border-color:            #ddd;
+// $nav-tabs-justified-active-link-border-color:     $body-bg;
+//
+// // Pills
+// $nav-pills-border-radius:                   $border-radius-base;
+// $nav-pills-active-link-hover-bg:            $component-active-bg;
+// $nav-pills-active-link-hover-color:         $component-active-color;
+//
+//
+// // Pagination
+// // -------------------------
+//
+// $pagination-bg:                        #fff;
+// $pagination-border:                    #ddd;
+//
+// $pagination-hover-bg:                  $gray-lighter;
+//
+// $pagination-active-bg:                 $brand-primary;
+// $pagination-active-color:              #fff;
+//
+// $pagination-disabled-color:            $gray-light;
+//
+//
+// // Pager
+// // -------------------------
+//
+// $pager-border-radius:                  15px;
+// $pager-disabled-color:                 $gray-light;
+//
+//
+// // Jumbotron
+// // -------------------------
+//
+// $jumbotron-padding:              30px;
+// $jumbotron-color:                inherit;
+// $jumbotron-bg:                   $gray-lighter;
+// $jumbotron-heading-color:        inherit;
+// $jumbotron-font-size:            ceil($font-size-base * 1.5);
+//
+//
+// // Form states and alerts
+// // -------------------------
+//
+// $state-success-text:             #468847;
+// $state-success-bg:               #dff0d8;
+// $state-success-border:           darken(spin($state-success-bg, -10), 5%);
+//
+// $state-info-text:                #3a87ad;
+// $state-info-bg:                  #d9edf7;
+// $state-info-border:              darken(spin($state-info-bg, -10), 7%);
+//
+// $state-warning-text:             #c09853;
+// $state-warning-bg:               #fcf8e3;
+// $state-warning-border:           darken(spin($state-warning-bg, -10), 5%);
+//
+// $state-danger-text:              #b94a48;
+// $state-danger-bg:                #f2dede;
+// $state-danger-border:            darken(spin($state-danger-bg, -10), 5%);
+//
+//
+// // Tooltips
+// // -------------------------
+// $tooltip-max-width:           200px;
+// $tooltip-color:               #fff;
+// $tooltip-bg:                  #000;
+//
+// $tooltip-arrow-width:         5px;
+// $tooltip-arrow-color:         $tooltip-bg;
+//
+//
+// // Popovers
+// // -------------------------
+// $popover-bg:                          #fff;
+// $popover-max-width:                   276px;
+// $popover-border-color:                rgba(0,0,0,.2);
+// $popover-fallback-border-color:       #ccc;
+//
+// $popover-title-bg:                    darken($popover-bg, 3%);
+//
+// $popover-arrow-width:                 10px;
+// $popover-arrow-color:                 #fff;
+//
+// $popover-arrow-outer-width:           ($popover-arrow-width + 1);
+// $popover-arrow-outer-color:           rgba(0,0,0,.25);
+// $popover-arrow-outer-fallback-color:  #999;
+//
+//
+// // Labels
+// // -------------------------
+//
+// $label-default-bg:            $gray-light;
+// $label-primary-bg:            $brand-primary;
+// $label-success-bg:            $brand-success;
+// $label-info-bg:               $brand-info;
+// $label-warning-bg:            $brand-warning;
+// $label-danger-bg:             $brand-danger;
+//
+// $label-color:                 #fff;
+// $label-link-hover-color:      #fff;
+//
+//
+// // Modals
+// // -------------------------
+// $modal-inner-padding:         20px;
+//
+// $modal-title-padding:         15px;
+// $modal-title-line-height:     $line-height-base;
+//
+// $modal-content-bg:                             #fff;
+// $modal-content-border-color:                   rgba(0,0,0,.2);
+// $modal-content-fallback-border-color:          #999;
+//
+// $modal-backdrop-bg:           #000;
+// $modal-header-border-color:   #e5e5e5;
+// $modal-footer-border-color:   $modal-header-border-color;
+//
+//
+// // Alerts
+// // -------------------------
+// $alert-padding:               15px;
+// $alert-border-radius:         $border-radius-base;
+// $alert-link-font-weight:      bold;
+//
+// $alert-success-bg:            $state-success-bg;
+// $alert-success-text:          $state-success-text;
+// $alert-success-border:        $state-success-border;
+//
+// $alert-info-bg:               $state-info-bg;
+// $alert-info-text:             $state-info-text;
+// $alert-info-border:           $state-info-border;
+//
+// $alert-warning-bg:            $state-warning-bg;
+// $alert-warning-text:          $state-warning-text;
+// $alert-warning-border:        $state-warning-border;
+//
+// $alert-danger-bg:             $state-danger-bg;
+// $alert-danger-text:           $state-danger-text;
+// $alert-danger-border:         $state-danger-border;
+//
+//
+// // Progress bars
+// // -------------------------
+// $progress-bg:                 #f5f5f5;
+// $progress-bar-color:          #fff;
+//
+// $progress-bar-bg:             $brand-primary;
+// $progress-bar-success-bg:     $brand-success;
+// $progress-bar-warning-bg:     $brand-warning;
+// $progress-bar-danger-bg:      $brand-danger;
+// $progress-bar-info-bg:        $brand-info;
+//
+//
+// // List group
+// // -------------------------
+// $list-group-bg:               #fff;
+// $list-group-border:           #ddd;
+// $list-group-border-radius:    $border-radius-base;
+//
+// $list-group-hover-bg:         #f5f5f5;
+// $list-group-active-color:     $component-active-color;
+// $list-group-active-bg:        $component-active-bg;
+// $list-group-active-border:    $list-group-active-bg;
+//
+// $list-group-link-color:          #555;
+// $list-group-link-heading-color:  #333;
+//
+//
+// // Panels
+// // -------------------------
+// $panel-bg:                    #fff;
+// $panel-inner-border:          #ddd;
+// $panel-border-radius:         $border-radius-base;
+// $panel-footer-bg:             #f5f5f5;
+//
+// $panel-default-text:          $gray-dark;
+// $panel-default-border:        #ddd;
+// $panel-default-heading-bg:    #f5f5f5;
+//
+// $panel-primary-text:          #fff;
+// $panel-primary-border:        $brand-primary;
+// $panel-primary-heading-bg:    $brand-primary;
+//
+// $panel-success-text:          $state-success-text;
+// $panel-success-border:        $state-success-border;
+// $panel-success-heading-bg:    $state-success-bg;
+//
+// $panel-warning-text:          $state-warning-text;
+// $panel-warning-border:        $state-warning-border;
+// $panel-warning-heading-bg:    $state-warning-bg;
+//
+// $panel-danger-text:           $state-danger-text;
+// $panel-danger-border:         $state-danger-border;
+// $panel-danger-heading-bg:     $state-danger-bg;
+//
+// $panel-info-text:             $state-info-text;
+// $panel-info-border:           $state-info-border;
+// $panel-info-heading-bg:       $state-info-bg;
+//
+//
+// // Thumbnails
+// // -------------------------
+// $thumbnail-padding:           4px;
+// $thumbnail-bg:                $body-bg;
+// $thumbnail-border:            #ddd;
+// $thumbnail-border-radius:     $border-radius-base;
+//
+// $thumbnail-caption-color:     $text-color;
+// $thumbnail-caption-padding:   9px;
+//
+//
+// // Wells
+// // -------------------------
+// $well-bg:                     #f5f5f5;
+//
+//
+// // Badges
+// // -------------------------
+// $badge-color:                 #fff;
+// $badge-link-hover-color:      #fff;
+// $badge-bg:                    $gray-light;
+//
+// $badge-active-color:          $link-color;
+// $badge-active-bg:             #fff;
+//
+// $badge-font-weight:           bold;
+// $badge-line-height:           1;
+// $badge-border-radius:         10px;
+//
+//
+// // Breadcrumbs
+// // -------------------------
+// $breadcrumb-bg:               #f5f5f5;
+// $breadcrumb-color:            #ccc;
+// $breadcrumb-active-color:     $gray-light;
+// $breadcrumb-separator:        "/";
+//
+//
+// // Carousel
+// // ------------------------
+//
+// $carousel-text-shadow:                        0 1px 2px rgba(0,0,0,.6);
+//
+// $carousel-control-color:                      #fff;
+// $carousel-control-width:                      15%;
+// $carousel-control-opacity:                    .5;
+// $carousel-control-font-size:                  20px;
+//
+// $carousel-indicator-active-bg:                #fff;
+// $carousel-indicator-border-color:             #fff;
+//
+// $carousel-caption-color:                      #fff;
+//
+//
+// // Close
+// // ------------------------
+// $close-font-weight:           bold;
+// $close-color:                 #000;
+// $close-text-shadow:           0 1px 0 #fff;
+//
+//
+// // Code
+// // ------------------------
+// $code-color:                  #c7254e;
+// $code-bg:                     #f9f2f4;
+//
+// $pre-bg:                      #f5f5f5;
+// $pre-color:                   $gray-dark;
+// $pre-border-color:            #ccc;
+// $pre-scrollable-max-height:   340px;
+//
+// // Type
+// // ------------------------
+// $text-muted:                  $gray-light;
+// $abbr-border-color:           $gray-light;
+// $headings-small-color:        $gray-light;
+// $blockquote-small-color:      $gray-light;
+// $blockquote-border-color:     $gray-lighter;
+// $page-header-border-color:    $gray-lighter;
+//
+// // Miscellaneous
+// // -------------------------
+//
+// // Hr border color
+// $hr-border:                   $gray-lighter;
+//
+// // Horizontal forms & lists
+// $component-offset-horizontal: 180px;
+//
+//
+// // Container sizes
+// // --------------------------------------------------
+//
+// // Small screen / tablet
+// $container-tablet:             ((720px + $grid-gutter-width));
+// $container-sm:                 $container-tablet;
+//
+// // Medium screen / desktop
+// $container-desktop:            ((940px + $grid-gutter-width));
+// $container-md:                 $container-desktop;
+//
+// // Large screen / wide desktop
+// $container-large-desktop:      ((1140px + $grid-gutter-width));
+// $container-lg:                 $container-large-desktop;

+ 191 - 0
resource/css/_wiki.scss

@@ -0,0 +1,191 @@
+
+pre.body {
+  border: solid 1px #ccc;
+  background: #f0f0f0;
+}
+div.body {
+  padding: 10px;
+}
+
+.revision-toc {
+  float: right;
+  font-size: .9em;
+  border: solid 1px #aaa;
+  border-radius: 5px;
+  max-width: 250px;
+
+  .revision-toc-head {
+    display: inline-block;
+    float: right;
+    border-left: solid 1px #aaa;
+    border-bottom: solid 1px #aaa;
+    border-radius: 0 5px;
+    padding: 3px 11px;
+    font-weight: bold;
+    background: #f0f0f0;
+    margin-left: 5px;
+    margin-bottom: 5px;
+
+    &.collapsed {
+      border: none;
+      margin: 0;
+    }
+  }
+  .revision-toc-content {
+    padding: 10px;
+
+    > ul {
+      margin: 4px 4px 4px 15px;
+      padding: 5px;
+    }
+  }
+}
+
+.revision-head {
+
+  .revision-head-link {
+    display: none;
+    font-size: 15px;
+    padding-top: 8px;
+    padding-left: 10px;
+  }
+  &:hover .revision-head-link {
+    display: inline-block;
+  }
+}
+
+.wiki {
+  line-height: 1.6em;
+
+  h1, h2, h3, h4, h5, h6 {
+    margin-top: 1.6em;
+    margin-bottom: .8em;
+
+    &:first-child {
+      margin-top: 0;
+    }
+  }
+
+  h1 {
+    font-size: 2.2em;
+    font-weight: bold;
+  }
+  h2 {
+    font-size: 1.8em;
+    font-weight: bold;
+  }
+  h3 {
+    font-size: 1.6em;
+    font-weight: bold;
+  }
+  h4 {
+    font-size: 1.4em;
+    font-weight: normal;
+  }
+  h5 {
+    font-size: 1.2em;
+    font-weight: normal;
+  }
+
+  p {
+    font-weight: normal;
+    margin-bottom: 9px;
+  }
+  blockquote {
+    font-size: 12px;
+  }
+
+  img {
+    margin: 5px;
+    box-shadow: 0 0 12px 0px #999;
+    border: solid 1px #999;
+  }
+
+  ul, ol {
+    padding-left: 18px;
+  }
+
+  // {{{ table (copied from bootstrap .table
+  table {
+    width: 100%;
+    margin-bottom: $line-height-computed;
+    // Cells
+    > thead,
+    > tbody,
+    > tfoot {
+      > tr {
+        > th,
+        > td {
+          padding: $table-cell-padding;
+          line-height: $line-height-base;
+          vertical-align: top;
+          border-top: 1px solid $table-border-color;
+        }
+      }
+    }
+    // Bottom align for column headings
+    > thead > tr > th {
+      vertical-align: bottom;
+      border-bottom: 2px solid $table-border-color;
+    }
+    // Remove top border from thead by default
+    > caption + thead,
+    > colgroup + thead,
+    > thead:first-child {
+      > tr:first-child {
+        > th,
+        > td {
+          border-top: 0;
+        }
+      }
+    }
+    // Account for multiple tbody instances
+    > tbody + tbody {
+      border-top: 2px solid $table-border-color;
+    }
+
+    // Nesting
+    table {
+      background-color: $body-bg;
+    }
+
+    // .table-bordered
+    border: 1px solid $table-border-color;
+    > thead,
+    > tbody,
+    > tfoot {
+      > tr {
+        > th,
+        > td {
+          border: 1px solid $table-border-color;
+        }
+      }
+    }
+    > thead > tr {
+      > th,
+      > td {
+        border-bottom-width: 2px;
+      }
+    }
+  }
+  // }}}
+}
+
+
+@media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { // {{{ tablet size
+  .crowi.main-container .main .wiki {
+  }
+} // }}}
+
+@media (max-width: $screen-xs-max) { // {{{ iPhone size
+  .crowi.main-container .main .wiki {
+  }
+} // }}}
+
+@media (max-width: $screen-sm-max) { // {{{ tablet and iphone size
+  .crowi.main-container .main .wiki {
+    img {
+      max-width: 100%;
+    }
+  }
+}

+ 121 - 0
resource/css/crowi-reveal.scss

@@ -0,0 +1,121 @@
+//@import "../../node_modules/reveal.js/css/theme/template/theme";
+
+.reveal {
+  font-size: 32px;
+  section * {
+    font-family: "Lucida Grande", "Hiragino Kaku Gothic Pro W3", Meiryo, san-serif;
+  }
+
+  section {
+    text-align: left;
+
+    &.only.present {
+      top: -25%;
+      h1, h2, h3, h4, h5, h6 {
+        font-size: 2.5em;
+      }
+    }
+
+    h1, h2, h3, h4, h5, h6 {
+      margin-bottom: 1em;
+      font-weight: bold;
+      line-height: 1.2em;
+      text-transform: none;
+      text-align: left;
+      text-shadow: none;
+    }
+
+    p, ul li, ol li {
+      line-height: 1.3em;
+    }
+
+    p {
+      margin-top: .5em;
+    }
+
+    ul {
+      margin-top: .2em;
+      margin-bottom: .1em;
+      li {
+        margin-bottom: .2em;
+      }
+    }
+
+    h1:first-child {
+      font-size: 2.2em;
+    }
+    h2:first-child {
+      font-size: 1.8em;
+    }
+    h3, h4, h5, h6 {
+      &:first-child {
+        font-size: 1.5em;
+      }
+    }
+
+    // {{{ table (copied from bootstrap .table
+    table {
+      width: 100%;
+      margin-bottom: 1em;
+
+      border-collapse: collapse;
+      tr, td, th {
+        border-collapse: collapse;
+      }
+
+      // Cells
+      > thead,
+      > tbody,
+      > tfoot {
+        > tr {
+          > th,
+          > td {
+            padding: 1em;
+            vertical-align: top;
+            border-top: 1px solid #999;
+          }
+        }
+      }
+      // Bottom align for column headings
+      > thead > tr > th {
+        vertical-align: bottom;
+        border-bottom: 2px solid #888;
+      }
+      // Remove top border from thead by default
+      > caption + thead,
+      > colgroup + thead,
+      > thead:first-child {
+        > tr:first-child {
+          > th,
+          > td {
+            border-top: 0;
+          }
+        }
+      }
+      // Account for multiple tbody instances
+      > tbody + tbody {
+        border-top: 2px solid #888;
+      }
+
+      // .table-bordered
+      border: 1px solid #999;
+      > thead,
+      > tbody,
+      > tfoot {
+        > tr {
+          > th,
+          > td {
+            border: 1px solid #999;
+          }
+        }
+      }
+      > thead > tr {
+        > th,
+        > td {
+          border-bottom-width: 2px;
+        }
+      }
+    }
+
+  }
+}

+ 358 - 0
resource/css/crowi.scss

@@ -0,0 +1,358 @@
+// import crowi variable
+@import 'variables';
+
+// import bootstrap
+@import 'bootstrap';
+
+@import 'font-awesome';
+
+// crowi component
+@import 'mixins';
+@import 'layout';
+@import 'form';
+@import 'wiki';
+@import 'admin';
+
+
+ul {
+  padding-left: 18px;
+}
+
+
+.meta {
+  background: #f0f0f0;
+  padding: 10px;
+  font-size: 0.9em;
+  color: #888;
+  margin-top: 10px;
+  border-radius: 5px;
+}
+
+.help-block {
+  font-size: .9em;
+}
+
+header nav ul img {
+  vertical-align: middle;
+}
+
+footer, aside {
+  h4:first-child, h3:first-child {
+    margin-top: 0;
+  }
+
+  h4 {
+    font-size: 1.1em;
+  }
+}
+
+
+.preview-body {
+  border-top: solid 1px #ccc;
+  padding-top: 5px;
+  padding-bottom: 5px;
+  max-height: 500px;
+  overflow: scroll;
+}
+
+.form-element {
+  margin-bottom: 1em;
+}
+
+article {
+  header {
+    margin-bottom: 20px;
+  }
+}
+
+footer {
+  h4,
+  h3 {
+    margin-bottom: 0.5em;
+    font-weight: normal;
+
+    &:first-child {
+      margin-top: 0;
+    }
+  }
+
+  p {
+    margin: 0.3em 0 0.5em 0;
+
+    &:first-child {
+      margin-top: 0;
+    }
+  }
+}
+
+.modal {
+  p {
+    font-size: 1em;
+  }
+
+  h3, h4 {
+    font-size: 18px;
+    margin: 0;
+  }
+}
+
+// {{{ add badge variation
+.badge-default {
+  @include label-variant($label-default-bg);
+}
+.badge-primary {
+  @include label-variant($label-primary-bg);
+}
+.badge-success {
+  @include label-variant($label-success-bg);
+}
+.badge-info {
+  @include label-variant($label-info-bg);
+}
+.badge-warning {
+  @include label-variant($label-warning-bg);
+}
+.badge-danger {
+  @include label-variant($label-danger-bg);
+}
+// }}}
+
+.dropdown-menu {
+  .dropdown-button {
+    padding: 3px 20px;
+  }
+}
+
+.notif {
+  a {
+  }
+
+  .badge {
+    position: absolute;
+    top: 6px;
+    right: 1px;
+    padding: 3px 6px;
+    font-size: 11px;
+    font-weight: normal;
+  }
+}
+
+.dropdown-menu {
+  li {
+    a {
+      padding: 5px 20px;
+    }
+  }
+}
+
+// user picture
+.picture {
+
+  // 通常サイズ
+  width: 24px;
+  height: 24px;
+
+  // size list
+  &.picture-lg {
+    width: 32px;
+    height: 32px;
+  }
+  &.picture-sm {
+    width: 16px;
+    height: 16px;
+  }
+  &.picture-xs {
+    width: 12px;
+    height: 12px;
+  }
+
+  // design option
+  &.picture-sq {
+    border-radius: 2px;
+    border: solid 1px #ccc;
+  }
+  &.picture-rounded {
+    border-radius: 50%;
+    box-shadow: 0 0 2px #ccc;
+  }
+}
+// components
+.flip-container { // {{{
+  perspective: 1000;
+
+  .flipper {
+    .front, .back {
+      -webkit-backface-visibility: hidden;
+      backface-visibility: hidden;
+      transition: 0.4s;
+      -webkit-transform-style: preserve-3d;
+      transform-style: preserve-3d;
+    }
+
+    .front {
+      z-index: 2;
+    }
+
+    .back {
+    }
+
+    .back,
+    &.to-flip .front {
+      -webkit-transform: rotateY(180deg);
+      transform: rotateY(180deg);
+    }
+
+    &.to-flip .back {
+      -webkit-transform: rotateY(0);
+      transform: rotateY(0);
+    }
+  }
+}
+
+// buttons
+.btn-primary {
+}
+$btn-facebook-color: #4c66a4;
+.btn-facebook {
+  @include button-variant(lighten($btn-facebook-color, 50%), $btn-facebook-color, darken($btn-facebook-color, 20%));
+}
+$btn-google-color: rgb(204,89,71);
+.btn-google {
+  @include button-variant(lighten($btn-google-color, 50%), $btn-google-color, darken($btn-google-color, 20%));
+}
+
+
+input.searching {
+  background: #fff url(/images/loading_s.gif) right no-repeat;
+}
+.search-list {
+  padding: 0;
+
+  li {
+    list-style: none;
+  }
+  .list-link {
+    padding-bottom: 5px;
+    a {
+      display: block;
+      word-break: break-all;
+      font-weight: bold;
+      text-decoration: none;
+      span {
+        font-weight: normal;
+      }
+      &:hover {
+        background: #f0f0f0;
+        color: #666;
+      }
+    }
+    .search-description {
+      font-size: .8em;
+      color: #999;
+    }
+  }
+
+  .next-link {
+    a {
+      display: block;
+      text-align: center;
+    }
+  }
+}
+
+.fk-hide {
+  display: none;
+}
+
+// notification
+.fk-notif {
+  width: 100%;
+  position: fixed;
+  bottom: -80px;
+  z-index: 8;
+  padding: 10px;
+  box-shadow: -1px 0 3px 0px #666;
+  font-weight: bold;
+  transition: all .3s;
+
+  &.fk-notif-danger {
+    background: #b94a48;
+    color: #fff;
+
+    a {
+      color: #f5ecf4;
+      text-decoration: underline;
+      &:hover {
+        text-decoration: none;
+      }
+    }
+  }
+
+  &.fk-notif-warning {
+    background: #fcf8e3;
+    color: #8a6d3b;
+  }
+}
+
+// external-services
+.crowi {
+  .github-link {
+    background: #e5f6f8;
+    padding: 1px;
+    border-radius: 3px;
+    display: inline-block;
+    border: solid 1px #ccc;
+    color: #555;
+    text-decoration: none;
+
+    &:hover {
+      background: #afdadf;
+    }
+  }
+}
+
+.fullscreen-layer {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 0;
+  background: rgba(0, 0, 0, .5);
+  z-index: 9999;
+  opacity: 0;
+  -webkit-transition: opacity .3s ease-out;
+  -moz-transition: opacity .3s ease-out;
+  transition: opacity .3s ease-out;
+
+  & > * {
+    box-shadow: 0 0 20px rgba(0, 0, 0, .8);
+  }
+}
+.overlay-on {
+  .container-fluid,
+  .crowi-header {
+    -webkit-filter: blur(5px);
+    -moz-filter: blur(5px);
+    filter: blur(5px);
+  }
+
+  .fullscreen-layer {
+    opacity: 1;
+    height: 100%;
+  }
+}
+
+#presentation-container {
+  position: absolute;
+  top: 5%;
+  left: 5%;
+  width: 90%;
+  height: 90%;
+  background: #000;
+
+  iframe {
+    width: 100%;
+    height: 100%;
+    border: 0;
+  }
+}

+ 256 - 0
resource/js/crowi.js

@@ -0,0 +1,256 @@
+/* jshint browser: true, jquery: true */
+/* global FB, marked */
+/* Author: Sotaro KARASAWA <sotarok@crocos.co.jp>
+*/
+
+var Crowi = {};
+
+Crowi.createErrorView = function(msg) {
+  $('#main').prepend($('<p class="alert-message error">' + msg + '</p>'));
+};
+
+Crowi.linkPath = function(revisionPath) {
+  var $revisionPath = revisionPath || '#revision-path';
+  var $title = $($revisionPath);
+  if (!$title.get(0)) {
+    return;
+  }
+
+  var path = '';
+  var pathHtml = '';
+  var splittedPath = $title.html().split(/\//);
+  splittedPath.shift();
+  splittedPath.forEach(function(sub) {
+    path += '/';
+    pathHtml += ' <a href="' + path + '">/</a> ';
+    if (sub) {
+      path += sub;
+      pathHtml += '<a href="' + path + '">' + sub + '</a>';
+    }
+  });
+  if (path.substr(-1, 1) != '/') {
+    path += '/';
+    pathHtml += ' <a href="' + path + '" class="last-path">/</a>';
+  }
+  $title.html(pathHtml);
+};
+
+Crowi.correctHeaders = function(contentId) {
+  // h1 ~ h6 の id 名を補正する
+  var $content = $(contentId || '#revision-body-content');
+  var i = 0;
+  $('h1,h2,h3,h4,h5,h6', $content).each(function(idx, elm) {
+    var id = 'head' + i++ + '-' + $(this).text().replace(/\/|\(|\)|\s|\?|\!|\.|\+|\*|\-|\=|\#|\~|\&|\^/g, '');
+    $(this).attr('id', id);
+    $(this).addClass('revision-head');
+    $(this).append('<span class="revision-head-link"><a href="#' + id +'"><i class="fa fa-link"></i></a></span>');
+  });
+};
+
+Crowi.revisionToc = function(contentId, tocId) {
+  var $content = $(contentId || '#revision-body-content');
+  var $tocId = $(tocId || '#revision-toc');
+
+  var $tocContent = $('<div id="revision-toc-content" class="revision-toc-content collapse"></div>');
+  $tocId.append($tocContent);
+
+  $('h1', $content).each(function(idx, elm) {
+    var id = $(this).attr('id');
+    var title = $(this).text();
+    var selector = '#' + id + ' ~ h2:not(#' + id + ' ~ h1 ~ h2)';
+
+    var $toc = $('<ul></ul>');
+    var $tocLi = $('<li><a href="#' + id +'">' + title + '</a></li>');
+
+
+    $tocContent.append($toc);
+    $toc.append($tocLi);
+
+    $(selector).each(function()
+    {
+      var id2 = $(this).attr('id');
+      var title2 = $(this).text();
+      var selector2 = '#' + id2 + ' ~ h3:not(#' + id2 + ' ~ h2 ~ h3)';
+
+      var $toc2 = $('<ul></ul>');
+      var $tocLi2 = $('<li><a href="#' + id2 +'">' + title2 + '</a></li>');
+
+      $tocLi.append($toc2);
+      $toc2.append($tocLi2);
+
+      $(selector2).each(function()
+      {
+        var id3 = $(this).attr('id');
+        var title3 = $(this).text();
+
+        var $toc3 = $('<ul></ul>');
+        var $tocLi3 = $('<li><a href="#' + id3 +'">' + title3 + '</a></li>');
+
+        $tocLi2.append($toc3);
+        $toc3.append($tocLi3);
+      });
+    });
+  });
+};
+
+
+Crowi.escape = function(s) {
+  s = s.replace(/&/g, '&amp;');
+  s = s.replace(/</g, '&lt;');
+  s = s.replace(/>/g, '&gt;');
+  s = s.replace(/"/g, '&quot;');
+  return s;
+};
+Crowi.unescape = function(s) {
+  s = s.replace(/&nbsp;/g, ' ');
+  s = s.replace(/&amp;/g, '&');
+  s = s.replace(/&lt;(?!\?)/g, '<');
+  s = s.replace(/([^\?])&gt;/g, '$1>');
+  s = s.replace(/&quot;/g, '"');
+  return s;
+};
+
+Crowi.getRendererType = function(format) {
+  if (!Crowi.rendererType[format]) {
+    throw new Error('no such renderer');
+  }
+
+  return new Crowi.rendererType[format]();
+};
+
+Crowi.rendererType = {};
+Crowi.rendererType.text = function(){};
+Crowi.rendererType.markdown = function(){};
+Crowi.rendererType.text.prototype = {
+  render: function($content) {
+    var $revisionHtml = this.$revisionBody.children('pre');
+    this.$content = $content;
+    $revisionHtml.html(this.$content.html());
+    this.expandImage();
+    this.link();
+  },
+  link: function () {
+    this.$revisionBody.html(this.$revisionBody.html().replace(/\s(https?:\/\/[\S]+)/g, ' <a href="$1">$1</a>'));
+  },
+  expandImage: function () {
+    this.$revisionBody.html(this.$revisionBody.html().replace(/\s(https?:\/\/[\S]+\.(jpg|jpeg|gif|png))/g, ' <img src="$1" class="auto-expanded-image" />'));
+  }
+};
+Crowi.rendererType.markdown.prototype = {
+  render: function($content) {
+    marked.setOptions({
+      gfm: true,
+      highlight: function (code, lang, callback) {
+        callback(null, code);
+        // あとで
+        //highlight: function (code, lang, callback) {
+        //  pygmentize({ lang: lang, format: 'html' }, code, function (err, result) {
+        //    if (err) return callback(err);
+        //    callback(null, result.toString());
+        //  });
+        //},
+      },
+      tables: true,
+      breaks: true,
+      pedantic: false,
+      sanitize: false,
+      smartLists: true,
+      smartypants: false,
+      langPrefix: 'lang-'
+    });
+
+    var contentHtml = Crowi.unescape(Crowi.escape($content.val()) || $content.html());
+    contentHtml = this.expandImage(contentHtml);
+    contentHtml = this.link(contentHtml);
+
+    var $body = this.$revisionBody;
+    // Using async version of marked
+    marked(contentHtml, {}, function (err, content) {
+      if (err) {
+        throw err;
+      }
+      $body.html(content);
+      //console.log(content);
+    });
+  },
+  link: function (content) {
+    return content
+      //.replace(/\s(https?:\/\/[\S]+)/g, ' <a href="$1">$1</a>') // リンク
+      .replace(/\s<((\/[^>]+?){2,})>/g, ' <a href="$1">$1</a>') // ページ間リンク: <> でかこまれてて / から始まり、 / が2個以上
+      ;
+  },
+  expandImage: function (content) {
+    return content.replace(/\s(https?:\/\/[\S]+\.(jpg|jpeg|gif|png))/g, ' <a href="$1"><img src="$1" class="auto-expanded-image"></a>');
+  }
+};
+
+Crowi.renderer = function (contentId, format, revisionBody) {
+  var $revisionBody = revisionBody || '#revision-body-content';
+
+  this.$content = $(contentId);
+  this.$revisionBody = $($revisionBody);
+  this.format = format;
+  this.renderer = Crowi.getRendererType(format);
+  this.renderer.$revisionBody = this.$revisionBody;
+};
+Crowi.renderer.prototype = {
+  render: function() {
+    this.renderer.render(this.$content);
+  }
+};
+
+$(function() {
+  Crowi.linkPath();
+
+  $('[data-toggle="tooltip"]').tooltip();
+  $('[data-tooltip-stay]').tooltip('show');
+
+  $('.copy-link').on('click', function () {
+    $(this).select();
+  });
+
+  $('#createMemo').on('shown.bs.modal', function (e) {
+    $('#memoName').focus();
+  });
+  $('#createMemoForm').submit(function(e)
+  {
+    var prefix = $('[name=memoNamePrefix]', this).val();
+    var name = $('[name=memoName]', this).val();
+    if (name === '') {
+      prefix = prefix.slice(0, -1);
+    }
+    top.location.href = prefix + name;
+
+    return false;
+  });
+
+  $('#renamePage').on('shown.bs.modal', function (e) {
+    $('#newPageName').focus();
+  });
+  $('#renamePageForm').submit(function(e) {
+    var path = $('#pagePath').html();
+    $.ajax({
+      type: 'POST',
+      url: '/_api/page_rename' + path,
+      data: $('#renamePageForm').serialize(),
+      dataType: 'json'
+    }).done(function(data) {
+      if (!data.status) {
+        $('#newPageNameCheck').html('<i class="fa fa-times-circle"></i> ' + data.message);
+        $('#newPageNameCheck').addClass('alert-danger');
+      } else {
+        $('#newPageNameCheck').removeClass('alert-danger');
+
+        $('#newPageNameCheck').html('<img src="/images/loading_s.gif"> 移動しました。移動先にジャンプします。');
+
+        setTimeout(function() {
+          top.location.href = data.page.path + '?renamed=' + path;
+        }, 1000);
+      }
+    });
+
+    return false;
+  });
+
+});
+

+ 145 - 0
routes/admin.js

@@ -0,0 +1,145 @@
+module.exports = function(app) {
+  'use strict';
+
+  var debug = require('debug')('crowi:routes:admin')
+    , models = app.set('models')
+    , Page = models.Page
+    , User = models.User
+
+    , MAX_PAGE_LIST = 5
+    , actions = {};
+
+  function createPager(currentPage, pageCount, itemCount, maxPageList) {
+    var pager = {};
+    pager.currentPage = currentPage;
+    pager.pageCount = pageCount;
+    pager.itemCount = itemCount;
+
+    pager.previous = null;
+    if (currentPage > 1) {
+      pager.previous = currentPage - 1;
+    }
+
+    pager.next = null;
+    if (currentPage < pageCount) {
+      pager.next = currentPage + 1;
+    }
+
+    pager.pages = [];
+    var pagerMin = Math.max(1, Math.ceil(currentPage - maxPageList/2));
+    var pagerMax = Math.min(pageCount, Math.floor(currentPage + maxPageList/2));
+    if (pagerMin == 1) {
+      if (MAX_PAGE_LIST < pageCount) {
+        pagerMax = MAX_PAGE_LIST;
+      } else {
+        pagerMax = pageCount;
+      }
+    }
+    if (pagerMax == pageCount) {
+      if ((pagerMax - MAX_PAGE_LIST) < 1) {
+        pagerMin = 1;
+      } else {
+        pagerMin = pagerMax - MAX_PAGE_LIST;
+      }
+    }
+
+    pager.previousDots = null;
+    if (pagerMin > 1) {
+      pager.previousDots = true;
+    }
+
+    pager.nextDots = null;
+    if (pagerMax < pageCount) {
+      pager.nextDots = true;
+    }
+
+    for (var i = pagerMin;
+      i <= pagerMax;
+      i++) {
+      pager.pages.push(i);
+    }
+
+    return pager;
+  }
+
+  actions.index = function(req, res) {
+    return res.render('admin/index');
+  };
+
+  actions.user = {};
+  actions.user.index = function(req, res) {
+    var page = parseInt(req.query.page) || 0;
+
+    User.findUsersWithPagination({page: page}, function(err, users, pageCount, itemCount) {
+      var pager = createPager(page, pageCount, itemCount, MAX_PAGE_LIST);
+      return res.render('admin/users', {
+        users: users,
+        pager: pager
+      });
+    });
+  };
+
+  actions.user.makeAdmin = function(req, res) {
+    var id = req.params.id;
+    User.findById(id, function(err, userData) {
+      userData.makeAdmin(function(err, userData) {
+        if (err === null) {
+          req.flash('successMessage', userData.name + 'さんのアカウントを管理者に設定しました。');
+        } else {
+          req.flash('errorMessage', '更新に失敗しました。');
+          debug(err, userData);
+        }
+        return res.redirect('/admin/users');
+      });
+    });
+  };
+
+  actions.user.removeFromAdmin = function(req, res) {
+    var id = req.params.id;
+    User.findById(id, function(err, userData) {
+      userData.removeFromAdmin(function(err, userData) {
+        if (err === null) {
+          req.flash('successMessage', userData.name + 'さんのアカウントを管理者から外しました。');
+        } else {
+          req.flash('errorMessage', '更新に失敗しました。');
+          debug(err, userData);
+        }
+        return res.redirect('/admin/users');
+      });
+    });
+  };
+
+  actions.user.activate = function(req, res) {
+    var id = req.params.id;
+    User.findById(id, function(err, userData) {
+      userData.statusActivate(function(err, userData) {
+        if (err === null) {
+          req.flash('successMessage', userData.name + 'さんのアカウントを承認しました');
+        } else {
+          req.flash('errorMessage', '更新に失敗しました。');
+          debug(err, userData);
+        }
+        return res.redirect('/admin/users');
+      });
+    });
+  };
+
+  actions.user.suspend = function(req, res) {
+    var id = req.params.id;
+
+    User.findById(id, function(err, userData) {
+      userData.statusSuspend(function(err, userData) {
+        if (err === null) {
+          req.flash('successMessage', userData.name + 'さんのアカウントを利用停止にしました');
+        } else {
+          req.flash('errorMessage', '更新に失敗しました。');
+          debug(err, userData);
+        }
+        return res.redirect('/admin/users');
+      });
+    });
+  };
+
+  return actions;
+};
+

+ 55 - 0
routes/index.js

@@ -0,0 +1,55 @@
+module.exports = function(app) {
+  var middleware = require('../lib/middlewares')
+    , form = require('../form')
+    , page = require('./page')(app)
+    , login = require('./login')(app)
+    , logout = require('./logout')(app)
+    , me = require('./me')(app)
+    , admin = require('./admin')(app)
+    , user = require('./user')(app);
+
+  app.get('/'                        , middleware.loginRequired() , page.pageListShow);
+  app.get('/login'                   , login.login);
+  app.post('/login'                  , form.login                 , login.login);
+  app.post('/register'               , form.register              , login.register);
+  app.get('/register'                , login.register);
+  app.post('/register/google'        , login.registerGoogle);
+  app.get('/google/callback'         , login.googleCallback);
+  app.get('/login/google'            , login.loginGoogle);
+  app.get('/login/facebook'          , login.loginFacebook);
+  app.get('/logout'                  , logout.logout);
+
+  app.get('/admin'                      , middleware.loginRequired() , middleware.adminRequired() , admin.index);
+  app.get('/admin/users'                , middleware.loginRequired() , middleware.adminRequired() , admin.user.index);
+  app.post('/admin/user/:id/makeAdmin'  , middleware.loginRequired() , middleware.adminRequired() , admin.user.makeAdmin);
+  app.post('/admin/user/:id/removeFromAdmin', middleware.loginRequired() , middleware.adminRequired() , admin.user.removeFromAdmin);
+  app.post('/admin/user/:id/activate'   , middleware.loginRequired() , middleware.adminRequired() , admin.user.activate);
+  app.post('/admin/user/:id/suspend'    , middleware.loginRequired() , middleware.adminRequired() , admin.user.suspend);
+
+  app.get('/me'                      , middleware.loginRequired() , me.index);
+  app.get('/me/password'             , middleware.loginRequired() , me.password);
+  app.post('/me'                     , form.me.user               , middleware.loginRequired() , me.index);
+  app.post('/me/password'            , form.me.password           , middleware.loginRequired() , me.password);
+  app.post('/me/picture/delete'      , middleware.loginRequired() , me.deletePicture);
+  app.post('/me/auth/facebook'       , middleware.loginRequired() , me.authFacebook);
+  app.post('/me/auth/google'         , middleware.loginRequired() , me.authGoogle);
+  app.get('/me/auth/google/callback' , middleware.loginRequired() , me.authGoogleCallback);
+
+  app.get('/_r/:id'                  , middleware.loginRequired() , page.api.redirector);
+  app.get('/_api/check_username'     , user.api.checkUsername);
+  app.post('/_api/me/picture/upload' , middleware.loginRequired() , me.api.uploadPicture);
+  app.get('/_api/user/bookmarks'     , middleware.loginRequired() , user.api.bookmarks);
+  app.post('/_api/page_rename/*'     , middleware.loginRequired() , page.api.rename);
+  app.post('/_api/page/:id/like'     , middleware.loginRequired() , page.api.like);
+  app.post('/_api/page/:id/unlike'   , middleware.loginRequired() , page.api.unlike);
+  app.get('/_api/page/:id/bookmark'  , middleware.loginRequired() , page.api.isBookmarked);
+  app.post('/_api/page/:id/bookmark' , middleware.loginRequired() , page.api.bookmark);
+  //app.get('/_api/page/*'           , user.useUserData()         , page.api.get);
+  //app.get('/_api/revision/:id'     , user.useUserData()         , revision.api.get);
+  //app.get('/_api/r/:revisionId'    , user.useUserData()         , page.api.get);
+
+  app.post('/*/edit'                 , form.revision              , middleware.loginRequired() , page.pageEdit);
+  app.get('/*/$'                     , middleware.loginRequired() , page.pageListShow);
+  app.get('/*'                       , middleware.loginRequired() , page.pageShow);
+  //app.get('/*/edit'                , routes.edit);
+};

+ 217 - 0
routes/login.js

@@ -0,0 +1,217 @@
+module.exports = function(app) {
+  'use strict';
+
+  var googleapis = require('googleapis')
+    , debug = require('debug')('crowi:routes:login')
+    , models = app.set('models')
+    , config = require('config')
+    , Page = models.Page
+    , User = models.User
+    , Revision = models.Revision
+    , actions = {};
+
+  var loginSuccess = function(req, res, userData) {
+    req.user = req.session.user = userData;
+    if (!userData.password) {
+      return res.redirect('/me/password');
+    }
+
+    var jumpTo = req.session.jumpTo;
+    if (jumpTo) {
+      req.session.jumpTo = null;
+      return res.redirect(jumpTo);
+    } else {
+      return res.redirect('/');
+    }
+  };
+
+  var loginFailure = function(req, res) {
+    req.flash('warningMessage', 'ログインに失敗しました');
+    return res.redirect('/login');
+  };
+
+  actions.googleCallback = function(req, res) {
+    var nextAction = req.session.googleCallbackAction || '/login';
+    debug('googleCallback.nextAction', nextAction);
+    req.session.googleAuthCode = req.query.code || '';
+
+    return res.redirect(nextAction);
+  };
+
+  actions.login = function(req, res) {
+    var loginForm = req.body.loginForm;
+
+    if (req.method == 'POST' && req.form.isValid) {
+      var email = loginForm.email;
+      var password = loginForm.password;
+
+      User.findUserByEmailAndPassword(email, password, function(err, userData) {
+        debug('on login findUserByEmailAndPassword', err, userData);
+        if (userData) {
+          loginSuccess(req, res, userData);
+        } else {
+          loginFailure(req, res);
+        }
+      });
+    } else { // method GET
+      return res.render('login', {
+      });
+    }
+  };
+
+  actions.loginGoogle = function(req, res) {
+    var code = req.session.googleAuthCode || null;
+
+    if (!code) {
+      require('../lib/googleAuth').createAuthUrl(req, function(err, redirectUrl) {
+        if (err) {
+          // TODO
+        }
+
+        req.session.googleCallbackAction = '/login/google';
+        return res.redirect(redirectUrl);
+      });
+    } else {
+      require('../lib/googleAuth').handleCallback(req, function(err, tokenInfo) {
+        console.log('handleCallback', err, tokenInfo);
+        if (err) {
+          return loginFailure(req, res);
+        }
+
+        var googleId = tokenInfo.user_id;
+        User.findUserByGoogleId(googleId, function(err, userData) {
+          console.log('findUserByGoogleId', err, userData);
+          if (!userData) {
+            return loginFailure(req, res);
+          }
+          return loginSuccess(req, res, userData);
+        });
+      });
+    }
+  };
+
+  actions.loginFacebook = function(req, res) {
+    var facebook = req.facebook;
+
+    facebook.getUser(function(err, fbId) {
+      if (err || !fbId) {
+        req.user = req.session.user = false;
+        return res.redirect('/login');
+      }
+
+      User.findUserByFacebookId(fbId, function(err, userData) {
+        console.log('on login findUserByFacebookId', err, userData);
+        if (userData) {
+          return loginSuccess(req, res, userData);
+        } else {
+          return loginFailure(req, res);
+        }
+      });
+    });
+  };
+
+  actions.register = function(req, res) {
+    var registerForm = req.body.registerForm || {};
+
+    // ログイン済みならさようなら
+    if (req.user) {
+      return res.redirect('/');
+    }
+
+    // config で closed ならさよなら
+    if (config.security.registrationMode == 'Closed') {
+      return res.redirect('/');
+    }
+
+    if (req.method == 'POST' && req.form.isValid) {
+      var name = registerForm.name;
+      var username = registerForm.username;
+      var email = registerForm.email;
+      var password = registerForm.password;
+      var facebookId = registerForm.fbId || null;
+      var googleId = registerForm.googleId || null;
+
+      // email と username の unique チェックする
+      User.isRegisterable(email, username, function (isRegisterable, errOn) {
+        var isError = false;
+        if (!User.isEmailValid(email)) {
+          isError = true;
+          req.flash('registerWarningMessage', 'このメールアドレスは登録できません。(ホワイトリストなどを確認してください)');
+        }
+        if (!isRegisterable) {
+          if (!errOn.username) {
+            isError = true;
+            req.flash('registerWarningMessage', 'このユーザーIDは利用できません。');
+          }
+          if (!errOn.email) {
+            isError = true;
+            req.flash('registerWarningMessage', 'このメールアドレスは登録済みです。');
+          }
+
+        }
+        if (isError) {
+          return res.render('login', {
+          });
+        }
+
+        User.createUserByEmailAndPassword(name, username, email, password, function(err, userData) {
+          if (err) {
+            req.flash('registerWarningMessage', 'ユーザー登録に失敗しました。');
+            return res.redirect('/login?register=1');
+          } else {
+            if (facebookId || googleId) {
+              userData.updateGoogleIdAndFacebookId(googleId, facebookId, function(err, userData) {
+                if (err) { // TODO
+                }
+                return loginSuccess(req, res, userData);
+              });
+            } else {
+              return loginSuccess(req, res, userData);
+            }
+          }
+        });
+      });
+    } else { // method GET
+      // google callback を受ける可能性もある
+      var code = req.session.googleAuthCode || null;
+
+      console.log('register. if code', code);
+      if (code) {
+        require('../lib/googleAuth').handleCallback(req, function(err, tokenInfo) {
+          if (err) {
+            req.flash('registerWarningMessage', 'Googleコネクト中にエラーが発生しました。');
+            return res.redirect('/login?register=1'); // TODO Handling
+          }
+
+          var googleId = tokenInfo.user_id;
+          var googleEmail = tokenInfo.email;
+          if (!User.isEmailValid(googleEmail)) {
+            req.flash('registerWarningMessage', 'このメールアドレスのGoogleアカウントはコネクトできません。');
+            return res.redirect('/login?register=1');
+          }
+
+          return res.render('login', {
+            googleId: googleId,
+            googleEmail: googleEmail,
+          });
+        });
+      } else {
+        return res.render('login', {
+        });
+      }
+    }
+  };
+
+  actions.registerGoogle = function(req, res) {
+    require('../lib/googleAuth').createAuthUrl(req, function(err, redirectUrl) {
+      if (err) {
+        // TODO
+      }
+
+      req.session.googleCallbackAction = '/register';
+      return res.redirect(redirectUrl);
+    });
+  };
+
+  return actions;
+};

+ 11 - 0
routes/logout.js

@@ -0,0 +1,11 @@
+module.exports = function(app) {
+  return {
+    logout: function(req, res) {
+
+      req.facebook.destroySession();
+      req.session.destroy();
+
+      return res.redirect('/');
+    }
+  };
+};

+ 239 - 0
routes/me.js

@@ -0,0 +1,239 @@
+module.exports = function(app) {
+  'use strict';
+
+  var fs = require('fs')
+    , models = app.set('models')
+    , Page = models.Page
+    , User = models.User
+    , Revision = models.Revision
+    , actions = {}
+    , api = {};
+
+  actions.api = api;
+
+  api.uploadPicture = function (req, res) {
+    var fileUploader = require('../lib/fileUploader');
+    var tmpFile = req.files.userPicture || null;
+    if (!tmpFile) {
+      return res.json({
+        'status': false,
+        'message': 'File type error.'
+      });
+    }
+
+    var tmpPath = tmpFile.path;
+    var filePath = User.createUserPictureFilePath(req.user, tmpFile.name);
+    var acceptableFileType = /image\/.+/;
+
+    if (!tmpFile.headers['content-type'].match(acceptableFileType)) {
+      return res.json({
+        'status': false,
+        'message': 'File type error. Only image files is allowed to set as user picture.',
+      });
+    }
+
+    fileUploader.uploadFile(
+      filePath,
+      tmpFile.headers['content-type'],
+      fs.createReadStream(tmpPath, {
+        flags: 'r',
+        encoding: null,
+        fd: null,
+        mode: '0666',
+        autoClose: true
+      }),
+      {},
+      function(err, data) {
+        if (err) {
+          return res.json({
+            'status': false,
+            'message': 'Error while uploading to ',
+          });
+        }
+        var imageUrl = fileUploader.generateS3FillUrl(filePath);
+        req.user.updateImage(imageUrl, function(err, data) {
+          fs.unlink(tmpPath, function (err) {
+            // エラー自体は無視
+            if (err) {
+              console.log('Error while deleting tmp file.');
+            }
+            return res.json({
+              'status': true,
+              'url': imageUrl,
+              'message': '',
+            });
+          });
+        });
+      }
+    );
+  };
+
+  actions.index = function(req, res) {
+    var userForm = req.body.userForm;
+    var userData = req.user;
+
+    if (req.method == 'POST' && req.form.isValid) {
+      var name = userForm.name;
+      var email = userForm.email;
+
+      if (!User.isEmailValid(email)) {
+        req.form.errors.push('このメールアドレスは登録できません。(ホワイトリストなどを確認してください)');
+        return res.render('me/index', {});
+      }
+
+      userData.update(name, email, function(err, userData) {
+        if (err) {
+          for (var e in err.errors) {
+            if (err.errors.hasOwnProperty(e)) {
+              req.form.errors.push(err.errors[e].message);
+            }
+          }
+          return res.render('me/index', {});
+        }
+
+        req.flash('successMessage', '更新しました');
+        return res.redirect('/me');
+      });
+    } else { // method GET
+      /// そのうちこのコードはいらなくなるはず
+      if (!userData.isEmailSet()) {
+        req.flash('warningMessage', 'メールアドレスが設定されている必要があります');
+      }
+
+      return res.render('me/index', {
+      });
+    }
+  };
+
+  actions.password = function(req, res) {
+    var passwordForm = req.body.mePassword;
+    var userData = req.user;
+
+    // パスワードを設定する前に、emailが設定されている必要がある (schemaを途中で変更したため、最初の方の人は登録されていないかもしれないため)
+    // そのうちこのコードはいらなくなるはず
+    if (!userData.isEmailSet()) {
+      return res.redirect('/me');
+    }
+
+    if (req.method == 'POST' && req.form.isValid) {
+      var newPassword = passwordForm.newPassword;
+      var newPasswordConfirm = passwordForm.newPasswordConfirm;
+      var oldPassword = passwordForm.oldPassword;
+
+      if (userData.isPasswordSet() && !userData.isPasswordValid(oldPassword)) {
+        req.form.errors.push('現在のパスワードが違います。');
+        return res.render('me/password', {
+        });
+      }
+
+      // check password confirm
+      if (newPassword != newPasswordConfirm) {
+        req.form.errors.push('確認用パスワードが一致しません');
+      } else {
+        userData.updatePassword(newPassword, function(err, userData) {
+          if (err) {
+            for (var e in err.errors) {
+              if (err.errors.hasOwnProperty(e)) {
+                req.form.errors.push(err.errors[e].message);
+              }
+            }
+            return res.render('me/password', {});
+          }
+
+          req.flash('successMessage', 'パスワードを変更しました');
+          return res.redirect('/me/password');
+        });
+      }
+    } else { // method GET
+      return res.render('me/password', {
+      });
+    }
+  };
+
+  actions.updates = function(req, res) {
+    res.render('me/update', {
+    });
+  };
+
+  actions.deletePicture = function(req, res) {
+    // TODO: S3 からの削除
+    req.user.deleteImage(function(err, data) {
+      req.flash('successMessage', 'プロフィール画像を削除しました');
+      res.redirect('/me');
+    });
+  };
+
+  actions.authGoogle = function(req, res) {
+    var userData = req.user;
+
+    var toDisconnect = req.body.disconnectGoogle ? true : false;
+    var toConnect = req.body.connectGoogle ? true : false;
+    if (toDisconnect) {
+      userData.deleteGoogleId(function(err, userData) {
+        req.flash('successMessage', 'Googleコネクトを解除しました。');
+
+        return res.redirect('/me');
+      });
+    } else if (toConnect) {
+      require('../lib/googleAuth').createAuthUrl(req, function(err, redirectUrl) {
+        if (err) {
+          // TODO
+        }
+
+        req.session.googleCallbackAction = '/me/auth/google/callback';
+        return res.redirect(redirectUrl);
+      });
+    } else {
+      return res.redirect('/me');
+    }
+  };
+
+  actions.authGoogleCallback = function(req, res) {
+    var userData = req.user;
+
+    require('../lib/googleAuth').handleCallback(req, function(err, tokenInfo) {
+      if (err) {
+        req.flash('warningMessage.auth.google', err.message); // FIXME: show library error message directly
+        return res.redirect('/me'); // TODO Handling
+      }
+
+      var googleId = tokenInfo.user_id;
+      var googleEmail = tokenInfo.email;
+      if (!User.isEmailValid(googleEmail)) {
+        req.flash('warningMessage.auth.google', 'このメールアドレスのGoogleアカウントはコネクトできません。');
+        return res.redirect('/me');
+      }
+      userData.updateGoogleId(googleId, function(err, userData) {
+        // TODO if err
+        req.flash('successMessage', 'Googleコネクトを設定しました。');
+        return res.redirect('/me');
+      });
+    });
+  };
+
+
+  actions.authFacebook = function(req, res) {
+    var userData = req.user;
+
+    var toDisconnect = req.body.disconnectFacebook ? true : false;
+    var fbId = req.body.fbId || 0;
+
+    if (toDisconnect) {
+      userData.deleteFacebookId(function(err, userData) {
+        req.flash('successMessage', 'Facebookコネクトを解除しました。');
+
+        return res.redirect('/me');
+      });
+    } else if (fbId) {
+      userData.updateFacebookId(fbId, function(err, userData) {
+        req.flash('successMessage', 'Facebookコネクトを設定しました。');
+
+        return res.redirect('/me');
+      });
+    } else {
+      return res.redirect('/me');
+    }
+  };
+
+  return actions;
+};

+ 323 - 0
routes/page.js

@@ -0,0 +1,323 @@
+module.exports = function(app) {
+  'use strict';
+
+  var debug = require('debug')('crowi:routes:page')
+    , models = app.set('models')
+    , Page = models.Page
+    , User = models.User
+    , Revision = models.Revision
+    , Bookmark = models.Bookmark
+    , actions = {};
+
+  function getPathFromRequest(req) {
+    var path = '/' + (req.params.shift() || '');
+    return path;
+  }
+
+  // TODO: total とかでちゃんと計算する
+  function generatePager(options) {
+    var next = null, prev = null,
+        offset = parseInt(options.offset, 10),
+        limit  = parseInt(options.limit, 10);
+
+    if (offset > 0) {
+      prev = offset - limit;
+      if (prev < 0) {
+        prev = 0;
+      }
+    }
+
+    next = offset + limit;
+
+    return {
+      prev: prev,
+      next: next,
+      offset: offset,
+      limit: limit
+    };
+  }
+
+  // routing
+  actions.pageListShow = function(req, res) {
+    var path = getPathFromRequest(req);
+    var options = {};
+
+    // index page
+    options = {
+      offset: req.query.offset || 0,
+      limit : req.query.limit  || 50
+    };
+    var q = Page.findListByStartWith(path, req.user, options, function(err, doc) {
+      if (err) {
+        // TODO : check
+      }
+      res.render('page_list', {
+        path: path + (path == '/' ? '' : '/'),
+        pages: doc,
+        pager: generatePager(options)
+      });
+    });
+  };
+
+  function renderPage(pageData, req, res) {
+    // create page
+    if (!pageData) {
+      return res.render('page', {
+        revision: {},
+        author: {},
+        page: false,
+      });
+    }
+
+    if (pageData.redirectTo) {
+      return res.redirect(pageData.redirectTo + '?renamed=' + pageData.path);
+    }
+
+    Revision.findRevisionList(pageData.path, {}, function(err, tree) {
+      res.render(req.query.presentation ? 'page_presentation' : 'page', {
+        path: pageData.path,
+        revision: pageData.revision || {},
+        author: pageData.revision.author || false,
+        page: pageData,
+        tree: tree || [],
+      });
+    });
+  }
+
+  actions.pageShow = function(req, res) {
+    var path = path || getPathFromRequest(req);
+    var options = {};
+
+    res.locals({path: path});
+
+    // pageShow は /* にマッチしてる最後の砦なので、creatableName でない routing は
+    // これ以前に定義されているはずなので、こうしてしまって問題ない。
+    if (!Page.isCreatableName(path)) {
+      debug('Page is not creatable name.');
+      res.redirect('/');
+      return ;
+    }
+
+    // single page
+    var parentPath = path.split('/').slice(0, -1).join('/'); // TODO : limitation
+    options = {
+    };
+
+    Page.findPage(path, req.user, req.query.revision, options, function(err, pageData) {
+      if (req.query.revision && err) {
+        res.redirect(path);
+        return ;
+      }
+
+      if (err == Page.PAGE_GRANT_ERROR) {
+        debug('PAGE_GRANT_ERROR');
+        return res.redirect('/');
+      }
+
+      pageData.seen(req.user, function(err, data) {
+        return renderPage(data, req, res);
+      });
+    });
+  };
+
+  actions.pageEdit = function(req, res) {
+    var io = module.parent.exports.io;
+    var path = getPathFromRequest(req);
+
+    var pageForm = req.body.pageForm;
+    var body = pageForm.body;
+    var format = pageForm.format;
+    var currentRevision = pageForm.currentRevision;
+    var grant = pageForm.grant;
+
+    if (!Page.isCreatableName(path)) {
+      res.redirect(path);
+      return ;
+    }
+
+    Page.findPage(path, req.user, null, {}, function(err, pageData){
+      if (!req.form.isValid) {
+        renderPage(pageData, req, res);
+        return;
+      }
+      if (pageData && !pageData.isUpdatable(currentRevision)) {
+        req.form.errors.push('すでに他の人がこのページを編集していたため保存できませんでした。ページを再読み込み後、自分の編集箇所のみ再度編集してください。');
+        renderPage(pageData, req, res);
+        return;
+      }
+
+      var cb = function(err, data) {
+        if (err) {
+          console.log('Page save error:', err);
+        }
+        app.set('io').sockets.emit('page edited', {page: data, user: req.user});
+
+        if (grant != data.grant) {
+          Page.updateGrant(data, grant, req.user, function (err, data) {
+            return res.redirect(path);
+          });
+        } else {
+          return res.redirect(path);
+        }
+      };
+      if (pageData) {
+        var newRevision = Revision.prepareRevision(pageData, body, req.user);
+        Page.pushRevision(pageData, newRevision, req.user, cb);
+      } else {
+        Page.create(path, body, req.user, {format: format}, cb);
+      }
+    });
+  };
+
+  var api = actions.api = {};
+
+  /**
+   * redirector
+   */
+  api.redirector = function(req, res){
+    var id = req.params.id;
+
+    var cb = function(err, d) {
+      if (err) {
+        return res.redirect('/');
+      }
+      return res.redirect(d.path);
+    };
+
+    Page.findPageById(id, req.user, function(err, pageData) {
+      if (pageData) {
+        if (pageData.grant == Page.GRANT_RESTRICTED && !pageData.isGrantedFor(req.user)) {
+          return Page.pushToGrantedUsers(pageData, req.user, cb);
+        } else {
+          return cb(null, pageData);
+        }
+      } else {
+        // 共有用URLにrevisionのidを使っていた頃の互換性のため
+        Revision.findRevision(id, cb);
+      }
+    });
+  };
+
+  /**
+   * page bookmark
+   */
+  api.isBookmarked = function(req, res){
+    var id = req.params.id;
+    Bookmark.findByPageIdAndUser(id, req.user, function(err, bookmark) {
+      debug('isBookmarked', id, req.user._id, err, bookmark);
+      if (err === null && bookmark) {
+        return res.json({bookmarked: true});
+      } else {
+        return res.json({bookmarked: false});
+      }
+    });
+  };
+
+  api.bookmark = function(req, res){
+    var id = req.params.id;
+    Page.findPageById(id, req.user, function(err, pageData) {
+      if (pageData) {
+        Bookmark.add(pageData, req.user, function(err, data) {
+          return res.json({status: true});
+        });
+      } else {
+        return res.json({status: false});
+      }
+    });
+  };
+
+  /**
+   * page like
+   */
+  api.like = function(req, res){
+    var id = req.params.id;
+    Page.findPageById(id, req.user, function(err, pageData) {
+      if (pageData) {
+        pageData.like(req.user, function(err, data) {
+          return res.json({status: true});
+        });
+      } else {
+        return res.json({status: false});
+      }
+    });
+  };
+
+  /**
+   * page like
+   */
+  api.unlike = function(req, res){
+    var id = req.params.id;
+
+    Page.findPageById(id, req.user, function(err, pageData) {
+      if (pageData) {
+        pageData.unlike(req.user, function(err, data) {
+          return res.json({status: true});
+        });
+      } else {
+        return res.json({status: false});
+      }
+    });
+  };
+
+  /**
+   * page rename
+   */
+  api.rename = function(req, res){
+    var path = Page.normalizePath(getPathFromRequest(req));
+
+    var val = req.body;
+    var previousRevision = val.previousRevision;
+    var newPageName = Page.normalizePath(val.newPageName);
+    var options = {
+      createRedirectPage: val.createRedirectPage || 0,
+      moveUnderTrees: val.moveUnderTrees || 0,
+    };
+
+    if (!Page.isCreatableName(newPageName)) {
+      return res.json({
+        message: 'このページ名は作成できません (' + newPageName + ')',
+        status: false,
+      });
+    }
+    Page.findPage(newPageName, req.user, null, {}, function(err, checkPageData){
+      if (checkPageData) {
+        return res.json({
+          message: 'このページ名は作成できません (' + newPageName + ')。ページが存在します。',
+          status: false,
+        });
+      }
+
+      Page.findPage(path, req.user, null, {}, function(err, pageData){
+        if (!pageData.isUpdatable(previousRevision)) {
+          return res.json({
+            message: '誰かが更新している可能性があります。ページを更新できません。',
+            status: false,
+          });
+        }
+        if (err) {
+          return res.json({
+            message: 'エラーが発生しました。ページを更新できません。',
+            status: false,
+          });
+        }
+
+        Page.rename(pageData, newPageName, req.user, options, function(err, pageData) {
+          if (err) {
+            return res.json({
+              message: 'ページの移動に失敗しました',
+              status: false,
+            });
+          }
+
+          return res.json({
+            message: '移動しました',
+            page: pageData,
+            status: true,
+          });
+        });
+      });
+    });
+  };
+
+  return actions;
+};

+ 41 - 0
routes/user.js

@@ -0,0 +1,41 @@
+module.exports = function(app) {
+  'use strict';
+
+   var models = app.set('models')
+    , Page = models.Page
+    , User = models.User
+    , Revision = models.Revision
+    , Bookmark = models.Bookmark
+    , actions = {}
+    , api = {};
+
+  actions.api = api;
+
+  api.bookmarks = function(req, res) {
+    var options = {
+      skip: req.query.offset || 0,
+      limit: req.query.limit || 50,
+    };
+    Bookmark.findByUser(req.user, options, function (err, bookmarks) {
+      res.json(bookmarks);
+    });
+  };
+
+  api.checkUsername = function(req, res) {
+    var username = req.query.username;
+
+    User.findUserByUsername(username, function(err, userData) {
+      if (userData) {
+        return res.json({
+          valid: false
+        });
+      } else {
+        return res.json({
+          valid: true
+        });
+      }
+    });
+  };
+
+  return actions;
+};

+ 13 - 0
views/500.html

@@ -0,0 +1,13 @@
+{% extends 'layout.html' %}
+
+{% block content_head %}
+<header>
+  <h2>Error</h2>
+</header>
+{% endblock %}
+
+{% block content_main %}
+
+Error: {{ error }}
+
+{% endblock %}

+ 94 - 0
views/_form.html

@@ -0,0 +1,94 @@
+{% if req.form.errors %}
+<div class="alert alert-danger">
+  <ul>
+  {% for error in req.form.errors %}
+    <li>{{ error }}</li>
+  {% endfor %}
+
+  </ul>
+</div>
+{% endif %}
+<div id="form-box">
+    <div class="row">
+      <div class="col-md-8">
+        <a href="javascript:;" id="form-box-full">最大化切り換え</a>
+        <form action="{{ path }}/edit" method="post" class="">
+          <div class="form-group">
+            <textarea name="pageForm[body]" class="form-control form-body-height" id="form-body">{% if pageForm.body %}{{ pageForm.body }}{% elseif not revision.body %}# {{ path|path2name }}{% else %}{{ revision.body }}{% endif %}</textarea>
+          </div>
+          <input type="hidden" name="pageForm[format]" value="markdown" id="form-format">
+          <input type="hidden" name="pageForm[currentRevision]" value="{{ pageForm.currentRevision|default(revision._id.toString()) }}">
+          <div class="form-group form-inline">
+            <select name="pageForm[grant]" class="form-control">
+              {% for grantId, grantLabel in consts.pageGrants %}
+              <option value="{{ grantId }}" {% if (pageForm.grant && grantId == pageForm.grant) || (page.grant == grantId ) %}selected{% endif %}>{{ grantLabel }}</option>
+              {% endfor %}
+            </select>
+
+            <input type="submit" class="btn btn-primary" id="edit-form-submit" value="ページを更新" />
+          </div>
+        </form>
+      </div>
+      <div class="col-md-4">
+        <div id="preview-body" class="wiki preview-body">
+        </div>
+      </div>
+    </div>
+    <script type="text/javascript">
+    $(function() {
+      // preview watch
+      var prevContent = "";
+      var watchTimer = setInterval(function() {
+        $('#preview-body').height($('#form-body').height() + 'px');
+        var content = $('#form-body').val();
+        if (prevContent != content) {
+          var renderer = new Crowi.renderer('#form-body', $('#form-format').val(), '#preview-body');
+          renderer.render();
+
+          prevContent = content;
+        }
+      }, 1000);
+
+      function maximizeFormBox()
+      {
+        $('#form-box').addClass('form-maximized');
+        $('#form-body').height($(window).height() - 150 + 'px');
+      }
+      function minimizeFormBox()
+      {
+        $('#form-box').removeClass('form-maximized');
+        $('#form-body').height('300px');
+      }
+      $('#form-box-full').toggle(function()
+      {
+        maximizeFormBox();
+      }, function() {
+        minimizeFormBox();
+      });
+
+      // tabs handle
+      $('textarea#form-body').on('keydown', function(event){
+        var self  = $(this)
+            start = this.selectionStart,
+            end   = this.selectionEnd
+            val   = self.val();
+
+        if (event.keyCode === 9) {
+          // tab
+          event.preventDefault();
+          self.val(
+            val.substring(0, start)
+            + '    '
+            + val.substring(end, val.length)
+          );
+          this.selectionStart = start + 4;
+          this.selectionEnd   = start + 4;
+        } else if (event.keyCode === 27) {
+          // escape
+          self.blur();
+        }
+      });
+    });
+
+    </script>
+</div>

+ 37 - 0
views/admin/index.html

@@ -0,0 +1,37 @@
+{% extends '../layout/2column.html' %}
+
+{% block html_title %}Wiki管理 · {{ path }}{% endblock %}
+
+{% block content_head %}
+<header id="page-header">
+  <h1 class="title" id="">Wiki管理</h1>
+</header>
+{% endblock %}
+
+{% block content_main %}
+<div class="content-main">
+  <div class="row">
+    <div class="col-md-3">
+      <ul class="nav nav-pills nav-stacked">
+        <li class="active"><a href="/admin"><i class="fa fa-cube"></i> Wiki管理トップ</a></li>
+        <li><a href="/admin/users"><i class="fa fa-users"></i> ユーザー管理</a></li>
+      </ul>
+    </div>
+    <div class="col-md-9">
+      <p>
+      この画面はWiki管理者のみがアクセスできる画面です。<br>
+      「ユーザー管理」から「管理者にする」ボタンを使ってユーザーをWiki管理者に任命することができます。
+      </p>
+    </div>
+  </div>
+
+</div>
+{% endblock content_main %}
+
+{% block content_footer %}
+{% endblock content_footer %}
+
+{% block footer %}
+{% endblock footer %}
+
+

+ 164 - 0
views/admin/users.html

@@ -0,0 +1,164 @@
+{% extends '../layout/2column.html' %}
+
+{% block html_title %}ユーザー管理 · {% endblock %}
+
+{% block content_head %}
+<header id="page-header">
+  <h1 class="title" id="">ユーザー管理</h1>
+</header>
+{% endblock %}
+
+{% block content_main %}
+<div class="content-main">
+  {% set smessage = req.flash('successMessage') %}
+  {% if smessage.length %}
+  <div class="alert alert-success">
+    {{ smessage }}
+  </div>
+  {% endif %}
+
+  {% set emessage = req.flash('errorMessage') %}
+  {% if emessage.length %}
+  <div class="alert alert-danger">
+    {{ emessage }}
+  </div>
+  {% endif %}
+
+  <div class="row">
+    <div class="col-md-3">
+      <ul class="nav nav-pills nav-stacked">
+        <li><a href="/admin"><i class="fa fa-cube"></i> Wiki管理トップ</a></li>
+        <li class="active"><a href="/admin/users"><i class="fa fa-users"></i> ユーザー管理</a></li>
+      </ul>
+    </div>
+    <div class="col-md-9">
+      <table class="table table-hover table-striped table-bordered">
+        <thead>
+          <tr>
+            <th>#</th>
+            <th>ユーザーID</th>
+            <th>名前</th>
+            <th>メールアドレス</th>
+            <th>作成日</th>
+            <th>最終ログイン</th>
+            <th>操作</th>
+          </tr>
+        </thead>
+        <tbody>
+          {% for sUser in users %}
+          <tr>
+            <td>
+              <img src="{{ sUser|picture }}" class="picture picture-rounded" />
+              <span class="label {{ css.userStatus(sUser) }}">
+                {{ consts.userStatus[sUser.status] }}
+              </span><br>
+              {% if sUser.admin %}
+              <span class="label label-primary">
+                Admin
+              </span>
+              {% endif %}
+            </td>
+            <td>
+              <strong>{{ sUser.username }}</strong>
+            </td>
+            <td>{{ sUser.name }}</td>
+            <td>{{ sUser.email }}</td>
+            <td>{{ sUser.createdAt|date('Y-m-d') }}</td>
+            <td>{{ sUser.lastLoginAt }}</td>
+            <td>
+              <div class="btn-group admin-user-menu">
+                <button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
+                  編集
+                  <span class="caret"></span>
+                </button>
+                <ul class="dropdown-menu" role="menu">
+                  <li class="dropdown-header">編集メニュー</li>
+                  <li>
+                    <a href="">編集</a>
+                  </li>
+                  <li class="divider"></li>
+                  <li class="dropdown-header">ステータス</li>
+                  <li class="dropdown-button">
+                  {% if sUser.status == 1 %}
+                  <form action="/admin/user/{{ sUser._id.toString() }}/activate" method="post">
+                    <button type="submit" class="btn btn-block btn-info">承認する</button>
+                  </form>
+                  {% endif  %}
+                  {% if sUser.status == 2 %}
+                  <form action="/admin/user/{{ sUser._id.toString() }}/suspend" method="post">
+                    <button type="submit" class="btn btn-block btn-warning">アカウント停止</button>
+                  </form>
+                  {% endif  %}
+                  {% if sUser.status == 3 %}
+                  <form action="/admin/user/{{ sUser._id.toString() }}/activate" method="post">
+                    <button type="submit" class="btn btn-block btn-default">元に戻す</button>
+                  </form>
+                  </li>
+                  <li class="dropdown-button">
+                  <form action="/admin/user/{{ sUser._id.toString() }}/suspend" method="post">
+                    <button type="submit" class="btn btn-block btn-danger">完全に削除する</button>
+                  </form>
+                  {% endif  %}
+
+                  {% if sUser.status == 2 %} {# activated な人だけこのメニューを表示 #}
+                  <li class="divider"></li>
+                  <li class="dropdown-header">管理者メニュー</li>
+
+                  <li class="dropdown-button">
+                    {% if sUser.admin %}
+                      {% if sUser.username != user.username %}
+                      <form action="/admin/user/{{ sUser._id.toString() }}/removeFromAdmin" method="post">
+                        <button type="submit" class="btn btn-block btn-danger">管理者からはずす</button>
+                      </form>
+                      {% else %}
+                      <p class="alert alert-danger">自分自身を管理者から外すことはできません</p>
+                      {% endif %}
+                    {% else %}
+                      <form action="/admin/user/{{ sUser._id.toString() }}/makeAdmin" method="post">
+                        <button type="submit" class="btn btn-block btn-danger">管理者にする</button>
+                      </form>
+                    {% endif %}
+                  </li>
+                  {% endif %}
+                </ul>
+              </div>
+            </td>
+          </tr>
+          {% endfor %}
+        </tbody>
+      </table>
+
+      <ul class="pagination">
+
+        <li {% if pager.currentPage == 1 %}class="disabled"{% endif %}>
+          <a href="/admin/users?page={{ pager.previous|default(1) }}">&laquo;</a>
+        </li>
+        {% if pager.previousDots %}
+        <li><a href="#">...</a></li>
+        {% endif  %}
+        {% for page in pager.pages %}
+        <li {% if pager.currentPage == page %}class="active"{% endif %}>
+          <a href="/admin/users?page={{ page }}">{{ page }}</a>
+        </li>
+        {% endfor %}
+        {% if pager.nextDots %}
+        <li><a href="#">...</a></li>
+        {% endif  %}
+        <li {% if pager.currentPage == pager.pageCount %}class="disabled"{% endif %}>
+          <a href="/admin/users?page={{ pager.next|default(pager.pageCount) }}">&raquo;</a>
+        </li>
+      </ul>
+
+    </div>
+  </div>
+</div>
+{% endblock content_main %}
+
+{% block content_footer %}
+{% endblock content_footer %}
+
+{% block footer %}
+{% endblock footer %}
+
+
+

+ 15 - 0
views/index.html

@@ -0,0 +1,15 @@
+{% extends 'layout/2column.html' %}
+
+{% block content_head %}
+<header>
+  <h2>Index</h2>
+</header>
+{% endblock %}
+
+{% block content_main %}
+
+  {% for page in pages %}
+    <a href="/{{ page.path }}">{{ page.path }} ({{page.updatedAt|date('Y-m-d H:i:s O')}})</a><br />
+  {% endfor %}
+
+{% endblock %}

+ 261 - 0
views/layout/2column.html

@@ -0,0 +1,261 @@
+{% extends 'layout.html' %}
+
+{% block layout_head_nav %}
+<nav class="crowi-header navbar navbar-default" role="navigation">
+  <!-- Brand and toggle get grouped for better mobile display -->
+  <div class="navbar-header">
+    <a class="navbar-brand" href="/">{% block title %}{{ config.app.title }}{% endblock %}</a>
+  </div>
+
+  <button type="button" class="navbar-toggle" data-toggle="collapse" data-target="#navbarCollapse">
+    <span class="sr-only">Toggle navigation</span>
+    <span class="icon-bar"></span>
+    <span class="icon-bar"></span>
+    <span class="icon-bar"></span>
+  </button>
+  <!-- Collect the nav links, forms, and other content for toggling -->
+  <div class="collapse navbar-collapse" id="navbarCollapse">
+    <ul class="nav navbar-nav">
+      <li class=""><a href="/INDEX">INDEX</a></li>
+    </ul>
+    <form id="headerSearch" class="navbar-form navbar-left form-inline" role="search">
+      <div class="form-group">
+        <input id="searchQuery" name="q" type="text" class="form-control" placeholder="検索文字...">
+        <button type="submit" class="btn btn-default">検索</button>
+        <script>
+          function Searcher () {
+          };
+          Searcher.prototype = {
+            baseUrl: "{{ config.searcher.url }}",
+            currentQuery: "",
+            searchData: [],
+            setData: function (data) {
+              this.searchData = data;
+            },
+            search: function (query) {
+              var self = this;
+              self.currentQuery = query;
+              if (query == "") {
+                return false;
+              }
+              $("#searchQuery").addClass('searching');
+              $.ajax({
+                url: self.baseUrl + '/search?type=json&callback=?',
+                data: {q: query},
+                cache: false,
+                dataType: 'jsonp'
+              }).done(function (data) {
+                self.setData(data);
+                self.showSearchWidget();
+                $("#searchQuery").removeClass('searching');
+              });
+            },
+            showSearchWidget: function () {
+              if (this.searchData.length > 0) {
+                $('#headerSearch').popover('show');
+              }
+              $("#searchQuery").removeClass('searching');
+            },
+            createSearchContent: function () {
+              var self = this;
+              var contentHtml = $('<ul class=\"search-list\"></ul>');
+              $.each(self.searchData.slice(0, 6), function (i, d) {
+                var $li = $("<li class=\"list-link\"></li>");
+                var $a = $("<a></a>");
+                $a.attr('href', d.path).html(d.path + "<br>");
+                var $span = $("<span class=\"search-description\"></span>");
+                $span.text(d.body.substr(0, 50) + "...");
+                $a.append($span);
+                $li.append($a);
+                contentHtml.append($li);
+              });
+
+              var $li = $("<li class=\"divider\"></li>");
+              contentHtml.append($li);
+              $li = $("<li class=\"next-link\"></li>");
+              $li.html("<a href=\"" + self.baseUrl + "/search?q=" + encodeURIComponent(self.currentQuery) + "\">もっと見る</a>");
+              contentHtml.append($li);
+              return contentHtml;
+            },
+            jump: function (query) {
+              self = this;
+              top.location.href = self.baseUrl + '/search?q=' + query;
+            }
+          };
+          var SearcherObject = new Searcher();
+
+          $('#headerSearch').popover({
+            placement: 'bottom',
+            trigger: 'manual',
+            html: 'true',
+            content: function () {
+              return SearcherObject.createSearchContent();
+            }
+          });
+          $('#searchQuery').on('focus', function(e) {
+            SearcherObject.showSearchWidget();
+          });
+          $('#searchQuery').on('blur', function(e) {
+            $('#headerSearch').popover('hide');
+          });
+          $('#headerSearch').on('submit', function(e) {
+            SearcherObject.jump($("#searchQuery").val());
+            return false;
+          });
+
+          var previousText = "";
+          setInterval(function (e) {
+            var text = $("#searchQuery").val();
+            if (text != previousText) {
+              SearcherObject.search(text);
+            }
+            previousText = text;
+          }, 1000);
+        </script>
+      </div>
+    </form>
+    <ul class="nav navbar-nav navbar-right">
+      <li class="aside-shown">
+        <a href="" class="layout-control to-show" id="toggle-sidebar-to-show"><i class="fa fa-caret-left"></i> サイドバー表示</a>
+        <script>
+          {# TODO 整理する #}
+          $('#toggle-sidebar-to-show').click(function(e) {
+            $('.main-container').removeClass('aside-hidden');
+            $.cookie('aside-hidden', 0, { expires: 30, path: '/' });
+
+            return false;
+          });
+        </script>
+      </li>
+
+      {% if user and user.admin %}
+      <li id="">
+        <a href="/admin" id="link-mypage">
+          <i class="fa fa-cube"></i> 管理
+        </a>
+      </li>
+      {% endif %}
+      {% if user %}
+      {#
+      <li id="" class="notif">
+        <a href="" id="notif-opener">
+          <i class="fa fa-globe"></i> <span class="badge badge-danger">6</span>
+        </a>
+        <script>
+          $('#notif-opener').popover({
+            placement: 'bottom',
+            trigger: 'manual',
+            html: 'true',
+            content: function () {
+              return '<div></div>';
+            }
+          });
+          $('#notif-opener').click(function(e) {
+            $('#notif-opener').popover('show');
+            return false;
+          });
+        </script>
+      </li>
+      #}
+      <li id="login-user">
+        <a href="/user/{{ user.username }}" id="link-mypage">
+          <img src="{{ user|picture }}" class="picture picture-rounded" width="25" /> マイページ
+        </a>
+      </li>
+      <li class="dropdown">
+        <a href="#" class="dropdown-toggle" data-toggle="dropdown"><i class="fa fa-bars"></i> <label class="sr-only">メニュー</label></a>
+        <ul class="dropdown-menu">
+          <li><a href="" data-target="#createMemo" data-toggle="modal"><i class="fa fa-pencil"></i> 今日のメモを作成</a></li>
+          <li class="divider"></li>
+          <li><a href="/me"><i class="fa fa-gears"></i> ユーザー設定</a></li>
+          <li class="divider"></li>
+          <li><a href="/logout"><i class="fa fa-sign-out"></i> ログアウト</a></li>
+          {# <li><a href="#">今日の日報を作成</a></li> #}
+          {# <li class="divider"></li> #}
+          {# <li class="divider"></li> #}
+          {# <li><a href="#">ログアウト</a></li> #}
+        </ul>
+      </li>
+      {% else %}
+      <li id="login-user"><a href="/login" id="login"><i class="fa fa-user"></i> Login</a></li>
+      {% endif %}
+      {% if config.security.confidential != '' %}
+      <li class="confidential"><a href="#">{{ config.security.confidential }}</a></li>
+      {% endif %}
+    </ul>
+  </div><!-- /.navbar-collapse -->
+</nav>
+{% include '../modal/widget_today_memo.html' %}
+{% endblock  %} {# layout_head_nav #}
+
+{% block layout_sidebar %}
+
+<a href="" class="layout-control to-hide" id="toggle-sidebar-to-hide"><i class="fa fa-chevron-right"></i> <span class="hide-on-affix-top"></span></a>
+<script>
+  $('#toggle-sidebar-to-hide').click(function(e) {
+    $('.main-container').addClass('aside-hidden');
+    $.cookie('aside-hidden', 1, { expires: 30, path: '/' });
+    return false;
+  });
+</script>
+<aside class="sidebar col-md-3 hidden-xs hidden-sm hidden-print">
+
+  {% block side_header %}
+  {% endblock %}
+
+  <div class="side-content">
+  {% block side_content %}
+  {% endblock %}
+  </div>
+
+  {% block side_footer %}
+  {% endblock %}
+
+  <div id="footer-container" class="footer">
+    <footer class="">
+      <p>&copy; 2012 {{ config.app.title }}. <a href="" data-target="#helpModal" data-toggle="modal"><i class="fa fa-question-circle"></i> ヘルプ</a></p>
+    </footer>
+  </div>
+</aside>
+{% include '../modal/widget_help.html' %}
+
+<script>
+  $(function() {
+    console.log($.cookie('aside-hidden'));
+    if ($.cookie('aside-hidden') == 1) {
+      console.log("add aside-hidden");
+      $('.main-container').addClass('aside-hidden');
+    }
+  });
+</script>
+{% endblock %} {# layout_sidebar #}
+
+{% block layout_main %}
+<div id="main" class="main col-md-9 {% if page %}{{ css.grant(page) }}{% endif %}" ng-controller="WikiPageController">
+  {% if page && page.grant != 1 %}
+  <p class="page-grant">
+    <i class="fa fa-lock"></i> {{ consts.pageGrants[page.grant] }} (このページの閲覧は制限されています)
+  </p>
+  {% endif %}
+  <article>
+  {% block content_head %}
+    <header>
+    <h2>-</h2>
+    <p>-</p>
+    </header>
+  {% endblock %}
+
+  {% block content_main %}
+  //
+  {% endblock content_main %}
+
+  {% block content_footer %}
+    <footer>
+    <h3>-</h3>
+    <p>-</p>
+    </footer>
+  {% endblock %}
+  </article>
+</div>
+
+{% endblock %} {# layout_main #}

+ 124 - 0
views/layout/layout.html

@@ -0,0 +1,124 @@
+<!DOCTYPE html>
+<html>
+{% block html_head %}
+<head>
+  <meta charset="utf-8">
+  <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
+
+  <title>{% block html_title %}{% endblock %} {{ config.app.title }}</title>
+  <meta name="description" content="">
+  <meta name="author" content="">
+
+  <meta name="viewport" content="width=device-width,initial-scale=1">
+
+  {% if env  == 'development' %}
+  <link rel="stylesheet" href="/css/crowi.css">
+  {% else %}
+  <link rel="stylesheet" href="/css/crowi.min.css">
+  {% endif %}
+
+  <script src="//ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js"></script>
+  <link href='http://fonts.googleapis.com/css?family=Maven+Pro:400,700' rel='stylesheet' type='text/css'>
+  {% if env  == 'development' %}
+  <script src="/js/crowi.js"></script>
+  {% else %}
+  <script src="/js/crowi.min.js"></script>
+  {% endif %}
+</head>
+{% endblock %}
+
+{% block html_body %}
+<body class="crowi main-container {% block html_base_css %}{% endblock %}">
+<div id="fb-root"></div>
+<script>
+  window.fbAsyncInit = function() {
+    FB.init({
+      appId      : '{{ facebook.appId }}', // App ID
+      //channelUrl : '//WWW.YOUR_DOMAIN.COM/channel.html', // Channel File
+      status     : true, // check login status
+      cookie     : true, // enable cookies to allow the server to access the session
+      xfbml      : true  // parse XFBML
+    });
+  };
+
+  (function(d){
+     var js, id = 'facebook-jssdk'; if (d.getElementById(id)) {return;}
+     js = d.createElement('script'); js.id = id; js.async = true;
+     js.src = "//connect.facebook.net/en_US/all.js";
+     d.getElementsByTagName('head')[0].appendChild(js);
+   }(document));
+</script>
+
+{% block layout_head_nav %}
+<nav class="crowi-header navbar navbar-default" role="navigation">
+  <div class="navbar-header">
+    <a class="navbar-brand" href="/">{% block title %}{{ config.app.title }}{% endblock %}</a>
+  </div>
+
+  <div class="collapse navbar-collapse">
+  </div><!-- /.navbar-collapse -->
+</nav>
+{% include '../modal/widget_today_memo.html' %}
+{% endblock  %} {# layout_head_nav #}
+
+<div class="container-fluid">
+  <div class="row">
+
+{% block layout_sidebar %}
+<aside class="sidebar col-md-3">
+    <div class="side-content">
+    {% block side_header %}
+    {% endblock %}
+
+    {% block side_content %}
+    {% endblock %}
+
+    {% block side_footer %}
+    {% endblock %}
+    </div>
+  </aside>
+</div>
+{% endblock %} {# layout_sidebar #}
+
+{% block layout_main %}
+<div id="main" class="main col-md-9" ng-controller="WikiPageController">
+  <article>
+  {% block content_head %}
+    <header>
+    <h2>-</h2>
+    <p>-</p>
+    </header>
+  {% endblock %}
+
+  {% block content_main %}
+  //
+  {% endblock content_main %}
+
+  {% block content_footer %}
+    <footer>
+    <h3>-</h3>
+    <p>-</p>
+    </footer>
+  {% endblock content_footer %}
+  </article>
+</div>
+{% endblock %} {# layout_main #}
+
+
+
+{% block footer %}
+{% endblock %}
+
+  </div> {# /.row #}
+</div> {# /.container-fluid #}
+
+{% block body_end %}
+{% endblock %}
+
+</body>
+{% endblock %}
+
+</html>
+
+
+

+ 22 - 0
views/layout/single-nologin.html

@@ -0,0 +1,22 @@
+{% extends 'layout.html' %}
+
+{% block html_base_css %}single nologin{% endblock %}
+
+{% block layout_head_nav %}
+{% endblock  %} {# layout_head_nav #}
+
+{% block layout_sidebar %}
+{% endblock  %} {# layout_sidebar  #}
+
+{% block layout_main %}
+
+  {% block content_head %}
+  {% endblock %}
+
+  {% block content_main %}
+  {% endblock content_main %}
+
+  {% block content_footer %}
+  {% endblock %}
+
+{% endblock %} {# layout_main #}

+ 1 - 0
views/layout/single.html

@@ -0,0 +1 @@
+{% extends 'layout.html' %}

+ 256 - 0
views/login.html

@@ -0,0 +1,256 @@
+{% extends 'layout/single-nologin.html' %}
+
+{% block html_title %}Login · {% endblock %}
+
+{% block content_main %}
+
+<h1 class="login-page" href="/">{% block title %}{{ config.app.title }}{% endblock %}</h1>
+
+<div class="login-dialog-container flip-container col-md-5">
+
+<div class="login-dialog flipper {% if req.query.register or req.body.registerForm or googleId %}to-flip{% endif %}" id="login-dialog">
+
+  <div class="login-dialog-inner front">
+    <h2>ログイン</h2>
+
+    <div id="login-form-errors">
+      {% set message = req.flash('warningMessage') %}
+      {% if message.length %}
+      <div class="alert alert-danger">
+        {{ message }}
+      </div>
+      {% endif %}
+
+      {% if req.form.errors.length > 0 %}
+      <div class="alert alert-danger">
+        <ul>
+        {% for error in req.form.errors %}
+          <li>{{ error }}</li>
+        {% endfor %}
+        </ul>
+      </div>
+      {% endif %}
+    </div>
+    <form role="form" action="/login" method="post">
+      <div class="input-group">
+        <span class="input-group-addon"><i class="fa fa-envelope"></i></span>
+        <input type="text" class="form-control" placeholder="E-mail" name="loginForm[email]">
+      </div>
+
+      <div class="input-group">
+        <span class="input-group-addon"><i class="fa fa-key"></i></span>
+        <input type="password" class="form-control" placeholder="Password" name="loginForm[password]">
+      </div>
+
+      <input type="submit" class="btn btn-primary btn-lg btn-block" value="Login">
+    </form>
+
+    <hr>
+
+    <div class="row">
+      <div class="col-md-6">
+        <p>Google でログイン</p>
+        <form role="form" action="/login/google" method="get">
+          <button type="submit" class="btn btn-block btn-google"><i class="fa fa-google-plus-square"></i> Login</button>
+        </form>
+      </div>
+      <div class="col-md-6">
+        <p>Facebook でログイン</p>
+        <form role="form">
+          <button type="button" id="btn-login-facebook" class="btn btn-block btn-facebook"><i class="fa fa-facebook-square"></i> Login</button>
+        </form>
+      </div>
+    </div>
+
+    {% if config.security.registrationMode != 'Closed' %}
+    <p class="bottom-text"><a href="#register" onclick="$('#login-dialog').addClass('to-flip'); return false;"><i class="fa fa-pencil"></i> 新規登録はこちら</a></p>
+    {% endif %}
+  </div>
+
+  {% if config.security.registrationMode != 'Closed' %}
+  <div class="register-dialog-inner back">
+
+    <h2>新規登録</h2>
+
+    {% if config.security.registrationMode != 'Closed' %}
+    <p class="alert alert-warning">
+    この Wiki への新規登録は制限されています。<br>
+    利用を開始するには、新規登録後、管理者による承認が必要です。
+    </p>
+    {% endif %}
+
+    {% if googleId %}
+    <div class="google-info">
+      <code>{{ email }}</code> この Google アカウントで登録します<br>
+      ユーザーID、名前、パスワードを決めて登録を継続してください。
+    </div>
+    {% endif %}
+
+    <div id="register-form-errors">
+      {% set message = req.flash('registerWarningMessage') %}
+      {% if message.length %}
+      <div class="alert alert-danger">
+        {% for msg in message %}
+        {{ msg }}<br>
+        {% endfor  %}
+      </div>
+      {% endif %}
+
+      {% if req.form.errors.length > 0 %}
+      <div class="alert alert-danger">
+        <ul>
+        {% for error in req.form.errors %}
+          <li>{{ error }}</li>
+        {% endfor %}
+        </ul>
+      </div>
+      {% endif %}
+    </div>
+
+    <form role="form" method="post" action="/register" id="register-form">
+      <input type="hidden" class="form-control" name="registerForm[fbId]" value="{{ req.body.registerForm.fbId }}">
+      <input type="hidden" class="form-control" name="registerForm[googleId]" value="{{ googleId|default(req.body.registerForm.googleId) }}">
+
+      <label>ユーザーID</label>
+      <div class="input-group" id="input-group-username">
+        <span class="input-group-addon"><strong>@</strong></span>
+        <input type="text" class="form-control" placeholder="記入例: taroyama" name="registerForm[username]" value="{{ req.body.registerForm.username }}" required>
+      </div>
+      <p class="help-block">
+      <span id="help-block-username" class="text-danger"></span>
+      ユーザーIDは、ユーザーページのURLなどに利用されます。半角英数字と一部の記号のみ利用できます。
+      </p>
+
+      <label>名前</label>
+      <div class="input-group">
+        <span class="input-group-addon"><i class="fa fa-user"></i></span>
+        <input type="text" class="form-control" placeholder="記入例: 山田 太郎" name="registerForm[name]" value="{{ req.body.registerForm.name }}" required>
+      </div>
+
+      <label >メールアドレス</label>
+      <div class="input-group">
+        <span class="input-group-addon"><i class="fa fa-envelope"></i></span>
+        <input type="email" class="form-control" placeholder="E-mail" name="registerForm[email]" value="{{ googleEmail|default(req.body.registerForm.email) }}" required>
+      </div>
+      {% if config.security && config.security.registrationWhiteList.length %}
+      <p class="help-block">
+      この Wiki では以下のメールアドレスのみ登録可能です。
+      </p>
+      <ul>
+        {% for em in config.security.registrationWhiteList %}
+        <li><code>{{ em }}</code></li>
+        {% endfor %}
+      </ul>
+      {% endif %}
+
+      <label>パスワード</label>
+      <div class="input-group">
+        <span class="input-group-addon"><i class="fa fa-key"></i></span>
+        <input type="password" class="form-control" placeholder="Password" name="registerForm[password]" required>
+      </div>
+      <p class="help-block">
+      パスワードは6文字以上の半角英数字または記号
+      </p>
+
+      <input type="submit" class="btn btn-primary btn-lg btn-block" value="新規登録">
+    </form>
+
+    <hr>
+
+    <div class="row">
+      <div class="col-md-6">
+        <p>Google で登録</p>
+        <form role="form" method="post" action="/register/google">
+          <button type="submit" class="btn btn-block btn-google"><i class="fa fa-google-plus-square"></i> Login</button>
+        </form>
+      </div>
+      <div class="col-md-6">
+        <p>Facebook で登録</p>
+        <form role="form">
+          <button type="button" id="btn-register-facebook" class="btn btn-block btn-facebook"><i class="fa fa-facebook-square"></i> Login</button>
+        </form>
+      </div>
+    </div>
+
+    <p class="bottom-text"><a href="#login" onclick="$('#login-dialog').removeClass('to-flip'); return false;"><i class="fa fa-sign-out"></i> ログインはこちら</a></p>
+  </div>
+  {% endif %} {# if registrationMode == Closed #}
+
+</div>
+
+</div>
+
+{#
+<div class="login-footer">
+  <p>&copy; {{ now|date('Y') }} {{ config.app.title }}. <a href="" data-target="#helpModal" data-toggle="modal"><i class="fa fa-question-circle"></i> ヘルプ</a></p>
+</div>
+#}
+
+<script>
+$(function() {
+  $('#btn-login-facebook').click(function(e)
+  {
+    var afterLogin = function(response) {
+      if (response.status !== 'connected') {
+        $('#login-form-errors').html('<p class="alert alert-danger">Facebookでのログインに失敗しました。</p>');
+      } else {
+        location.href = '/login/facebook';
+      }
+    };
+    FB.getLoginStatus(function(response) {
+      if (response.status === 'connected') {
+        afterLogin(response);
+      } else {
+        FB.login(function(response) {
+          afterLogin(response);
+        }, {scope: 'email'});
+      }
+    });
+  });
+
+  $('#register-form input[name="registerForm[username]"]').change(function(e) {
+    var username = $(this).val();
+    $('#input-group-username').removeClass('has-error');
+    $('#help-block-username').html("");
+
+    $.getJSON('/_api/check_username', {username: username}, function(json) {
+      if (!json.valid) {
+        $('#help-block-username').html('<i class="fa fa-warning"></i>このユーザーIDは利用できません。<br>');
+        $('#input-group-username').addClass('has-error');
+      }
+    });
+  });
+
+  $('#btn-register-facebook').click(function(e)
+  {
+    var afterLogin = function(response) {
+      if (response.status !== 'connected') {
+        $('#register-form-errors').html('<p class="alert alert-danger">Facebookでのログインに失敗しました。</p>');
+
+      } else {
+        var authR = response.authResponse;
+        $('#register-form input[name="registerForm[fbId]"]').val(authR.userID);
+        FB.api('/me?fields=name,username,email', function(res) {
+          $('#register-form input[name="registerForm[name]"]').val(res.name);
+          $('#register-form input[name="registerForm[username]"]').val(res.username || '');
+          $('#register-form input[name="registerForm[email]"]').val(res.email);
+
+          $('#register-form .facebook-info').remove();
+          $('#register-form').prepend('<div class="facebook-info"><img src="//graph.facebook.com/' + res.id + '/picture?size=square" width="25"> <i class="fa fa-facebook-square"></i> ' + res.name + 'さんとして登録します</div>');
+        });
+      }
+    };
+    FB.getLoginStatus(function(response) {
+      if (response.status === 'connected') {
+        afterLogin(response);
+      } else {
+        FB.login(function(response) {
+          afterLogin(response);
+        }, {scope: 'email'});
+      }
+    });
+  });
+});
+</script>
+
+{% endblock %}

+ 362 - 0
views/me/index.html

@@ -0,0 +1,362 @@
+{% extends '../layout/2column.html' %}
+
+{% block html_title %}ユーザー設定 · {{ path }}{% endblock %}
+
+{% block content_head %}
+<header  id="page-header">
+  <h1 class="title" id="">ユーザー設定</h1>
+</header>
+{% endblock %}
+
+{% block content_main %}
+<div class="content-main">
+
+  <ul class="nav nav-tabs">
+    <li class="active"><a href="/me"><i class="fa fa-gears"></i> ユーザー情報</a></li>
+    <li><a href="/me/password"><i class="fa fa-key"></i> パスワード設定</a></li>
+  </ul>
+
+  <div class="tab-content">
+
+  {% set smessage = req.flash('successMessage') %}
+  {% if smessage.length %}
+  <div class="alert alert-success">
+    {{ smessage }}
+  </div>
+  {% endif %}
+
+  {% set wmessage = req.flash('warningMessage') %}
+  {% if wmessage.length %}
+  <div class="alert alert-danger">
+    {{ wmessage }}
+  </div>
+  {% endif %}
+
+  {% if req.form.errors.length > 0 %}
+  <div class="alert alert-danger">
+    <ul>
+    {% for error in req.form.errors %}
+      <li>{{ error }}</li>
+    {% endfor %}
+    </ul>
+  </div>
+  {% endif %}
+
+
+  <div class="form-box">
+    <form action="/me" method="post" class="form-horizontal" role="form">
+      <fieldset>
+        <legend>ユーザーの基本情報</legend>
+      <div class="form-group">
+        <label for="userForm[name]" class="col-sm-2 control-label">名前</label>
+        <div class="col-sm-4">
+          <input class="form-control" type="text" name="userForm[name]" value="{{ user.name }}" required>
+        </div>
+      </div>
+      <div class="form-group {% if not user.email %}has-error{% endif %}">
+        <label for="userForm[email]" class="col-sm-2 control-label">メールアドレス</label>
+        <div class="col-sm-4">
+          <input class="form-control" type="email" name="userForm[email]" value="{{ user.email }}" required>
+        </div>
+        <div class="col-sm-offset-2 col-sm-10">
+          {# ↓ そのうちこのコードは削除する #}
+          {% if not user.email %}
+          <p class="help-block help-danger">
+          メールアドレスは登録必須項目です。<br>
+          (以前のバージョンのWikiで作成されたユーザー情報の場合、メールアドレスが登録されていません)<br>
+          更新ボタンを押して新規登録してください。
+          </p>
+          {% endif %}
+
+          {% if config.security && config.security.registrationWhiteList.length %}
+          <p class="help-block">
+          この Wiki では以下のメールアドレスのみ登録可能です。
+          <ul>
+            {% for em in config.security.registrationWhiteList %}
+            <li><code>{{ em }}</code></li>
+            {% endfor %}
+          </ul>
+          </p>
+          {% endif %}
+        </div>
+      </div>
+
+      <div class="form-group">
+        <div class="col-sm-offset-2 col-sm-10">
+          <button type="submit" class="btn btn-primary">更新</button>
+        </div>
+      </div>
+    </fieldset>
+    </form>
+  </div>
+
+  <div class="form-box">
+    <fieldset>
+      <legend>プロフィール画像の設定</legend>
+        <div class="form-group">
+          <div id="pictureUploadFormMessage"></div>
+          <label for="" class="col-sm-3 control-label">
+            現在の画像
+          </label>
+          <div class="col-sm-9">
+            <p>
+            <img src="{{ user|picture }}" width="64" id="settingUserPicture"><br>
+            </p>
+            <p>
+            {% if user.image %}
+            <form action="/me/picture/delete" method="post" class="form-horizontal" role="form" onsubmit="return window.confirm('削除してよろしいですか?');">
+              <button type="submit" class="btn btn-danger">画像を削除</button>
+            </form>
+            {% elseif user.fbId %}
+            プロフィール画像はFacebookから自動的に設定されています。
+            {% endif %}
+            </p>
+          </div>
+        </div> {# /.form-group# #}
+
+        <div class="form-group">
+          <label for="" class="col-sm-3 control-label">
+            新しい画像をアップロード
+          </label>
+          <div class="col-sm-9">
+            <form action="/_api/me/picture/upload" id="pictureUploadForm" method="post" class="form-horizontal" role="form" enctype="multipart/form-data">
+              <input name="userPicture" type="file" accept="image/*">
+              <div id="pictureUploadFormProgress">
+              </div>
+            </form>
+          </div>
+        </div>
+      </fieldset>
+    </div>
+    <script>
+    $(function()
+    {
+      $("#pictureUploadForm input[name=userPicture]").on('change', function(){
+        var $form = $('#pictureUploadForm');
+        var fd = new FormData($form[0]);
+        if ($(this).val() == '') {
+          return false;
+        }
+
+        $('#pictureUploadFormProgress').html('<img src="/images/loading_s.gif"> アップロード中...');
+        $.ajax($form.attr("action"), {
+          type: 'post',
+          processData: false,
+          contentType: false,
+          data: fd,
+          dataType: 'json',
+          success: function(data){
+            if (data.status) {
+              $('#settingUserPicture').attr('src', data.url + '?time=' + (new Date()));
+              $('#pictureUploadFormMessage')
+                .addClass('alert alert-success')
+                .html('変更しました');
+            } else {
+              $('#pictureUploadFormMessage')
+                .addClass('alert alert-danger')
+                .html('変更中にエラーが発生しました。');
+            }
+            $('#pictureUploadFormProgress').html('');
+          }
+        });
+        return false;
+      });
+    });
+    </script>
+
+  <div class="row">
+    <div class="col-sm-6"> {# Facebook Connect #}
+
+      <div class="form-box">
+        <form action="/me/auth/facebook" method="post" class="form-horizontal" role="form" id="auth-connect-facebook">
+          <fieldset>
+            <legend><i class="fa fa-facebook-square"></i> Facebook設定</legend>
+
+          {% if user.userId %}
+
+          <div class="form-group">
+            <div class="col-sm-12">
+              <p>
+                <a href="//www.facebook.com/{{ user.userId }}"><img src="//graph.facebook.com/{{ user.userId }}/picture?size=square" width="32"> </a>
+                <input type="submit" name="disconnectFacebook" class="btn btn-default" value="接続を解除">
+              </p>
+              <p class="help-block">
+              接続を解除すると、Facebookを利用してのログインができなくなります。<br>
+              解除後はメールアドレスとパスワードでログインすることができます。
+              </p>
+            </div>
+          </div>
+
+          {% else %}
+
+          <div class="form-group">
+            <div class="col-sm-12">
+              <div class="text-center">
+                <input type="hidden" class="form-control" name="fbId">
+                <button type="submit" id="btn-connect-facebook" class="btn btn-facebook">Facebookコネクト</button>
+                <script>
+                  $('#btn-connect-facebook').click(function(e)
+                  {
+                    var afterLogin = function(response) {
+                      if (response.status !== 'connected') {
+                        // TODO
+                      } else {
+                        var authR = response.authResponse;
+                        $('#auth-connect-facebook input[name="fbId"]').val(authR.userID);
+                        $('#auth-connect-facebook').submit();
+                      }
+                    };
+                    FB.getLoginStatus(function(response) {
+                      if (response.status === 'connected') {
+                        afterLogin(response);
+                      } else {
+                        FB.login(function(response) {
+                          afterLogin(response);
+                        }, {scope: 'email'});
+                      }
+                    });
+
+                    return false;
+                  });
+                </script>
+              </div>
+              <p class="help-block">
+              Facebookコネクトをすると、Facebookでログイン可能になります。<br>
+              メールアドレスとパスワードでのログインは引き続きご利用いただけます。
+              </p>
+            </div>
+          </div>
+
+          {% endif %}
+          </div>
+        </fieldset>
+        </form>
+    </div> {# /Facebook Connect #}
+
+    <div class="col-sm-6"> {# Google Connect #}
+
+      <div class="form-box">
+        <form action="/me/auth/google" method="post" class="form-horizontal" role="form">
+          <fieldset>
+            <legend><i class="fa fa-google-plus-square"></i> Google設定</legend>
+
+            {% set wmessage = req.flash('warningMessage.auth.google') %}
+            {% if wmessage.length %}
+            <div class="alert alert-danger">
+              {{ wmessage }}
+            </div>
+            {% endif %}
+
+            <div class="form-group">
+            {% if user.googleId %}
+
+            <div class="col-sm-12">
+              <p>
+                接続されています
+
+                <input type="submit" name="disconnectGoogle" class="btn btn-default" value="接続を解除">
+              </p>
+              <p class="help-block">
+              接続を解除すると、Googleでログインができなくなります。<br>
+              解除後はメールアドレスとパスワードでログインすることができます。
+              </p>
+            </div>
+
+            {% else %}
+
+            <div class="col-sm-12">
+              <div class="text-center">
+                <input type="submit" name="connectGoogle" class="btn btn-google" value="Googleコネクト">
+              </div>
+              <p class="help-block">
+              Googleコネクトをすると、Googleアカウントでログイン可能になります。<br>
+              </p>
+              {% if config.security && config.security.registrationWhiteList.length %}
+              <p class="help-block">
+              この Wiki では、登録可能なメールアドレスが限定されているため、コネクト可能なGoogleアカウントは、以下のメールアドレスの発行できるGoogle Appsアカウントに限られます。
+              </p>
+              <ul>
+                {% for em in config.security.registrationWhiteList %}
+                <li><code>{{ em }}</code></li>
+                {% endfor %}
+              </ul>
+              {% endif %}
+            </div>
+
+            {% endif %}
+          </div>
+        </fieldset>
+      </form>
+    </div> {# /Google Connect #}
+  </div>
+
+  {#
+  <div class="form-box">
+    <form action="/me/username" method="post" class="form-horizontal" role="form">
+      <fieldset>
+        <legend>プロフィール画像の設定</legend>
+      <div class="form-group">
+        <label for="" class="col-sm-2 control-label">
+          画像の設定
+        </label>
+        <div class="col-sm-6">
+          <p>
+            <img src="//graph.facebook.com/{{ user.userId }}/picture?size=square" width="32"><br>
+          </p>
+          <p>
+            <button class="btn btn-danger">画像を削除</button>
+          </p>
+        </div>
+      </div>
+      <div class="form-group">
+        <div class="col-sm-offset-2 col-sm-10">
+          <input name="" type="file">
+          <button type="submit" class="btn btn-primary">新しい画像をアップロード</button>
+        </div>
+      </div>
+      </div>
+    </fieldset>
+    </form>
+  </div>
+  #}
+
+  </div> {# end of .tab-contents #}
+
+  {#
+  <div class="form-box">
+    <form action="/me/username" method="post" class="form-horizontal" role="form">
+      <fieldset>
+        <legend>ユーザーID (ユーザー名) の変更</legend>
+      <div class="form-group">
+        <label for="userNameForm[username]" class="col-sm-2 control-label">ユーザーID</label>
+        <div class="col-sm-4">
+          <input class="form-control" type="text" name="userNameForm[username]" value="{{ user.username }}" required>
+          <p class="help-block">すべてのマイページの</p>
+        </div>
+      </div>
+
+      <div class="form-group">
+        <div class="col-sm-offset-2 col-sm-10">
+          <p class="alert alert-warning">
+          ユーザーIDを変更すると、<code>/user/{{ user.username }}</code> 以下のページがすべて <code>/user/新しいユーザーID</code> の下に移動されます。<br>
+          また、これまでのページにリダイレクトは設定されず、この操作の取り消しもできません。<br>
+          実行には十分に注意をしてください。
+          </p>
+          <button type="submit" class="btn btn-warning">ユーザーIDの変更を実行する</button>
+        </div>
+      </div>
+    </fieldset>
+    </form>
+  </div>
+  #}
+
+  </div>
+</div>
+{% endblock content_main %}
+
+{% block content_footer %}
+{% endblock content_footer %}
+
+{% block footer %}
+{% endblock footer %}
+

+ 102 - 0
views/me/password.html

@@ -0,0 +1,102 @@
+{% extends '../layout/2column.html' %}
+
+{% block html_title %}パスワードの設定 · {{ path }}{% endblock %}
+
+{% block content_head %}
+<header  id="page-header">
+  <h1 class="title" id="">ユーザー設定</h1>
+</header>
+{% endblock %}
+
+{% block content_main %}
+<div class="content-main">
+
+  <ul class="nav nav-tabs">
+    <li><a href="/me"><i class="fa fa-gears"></i> ユーザー情報</a></li>
+    <li class="active"><a href="/me/password"><i class="fa fa-key"></i> パスワード設定</a></li>
+  </ul>
+
+  <div class="tab-content">
+
+  {% if not user.password %}
+  <div class="alert alert-danger">
+    パスワードを設定してください
+  </div>
+  {% endif %}
+
+  {% set message = req.flash('successMessage') %}
+  {% if message.length %}
+  <div class="alert alert-success">
+    {{ message }}
+  </div>
+  {% endif %}
+
+  {% if req.form.errors.length > 0 %}
+  <div class="alert alert-danger">
+    <ul>
+    {% for error in req.form.errors %}
+      <li>{{ error }}</li>
+    {% endfor %}
+    </ul>
+  </div>
+  {% endif %}
+
+  {% if user.email %}
+  <p>
+  <code>{{ user.email }}</code> と設定されたパスワードの組み合わせでログイン可能になります。
+  </p>
+  {% endif %}
+
+  <div id="form-box">
+
+    <form action="/me/password" method="post" class="form-horizontal" role="form">
+    <fieldset>
+      {% if user.password %}
+      <legend>パスワードを更新</legend>
+      {% else %}
+      <legend>パスワードを新規に設定</legend>
+      {% endif %}
+      {% if user.password %}
+      <div class="form-group">
+        <label for="mePassword[oldPassword]" class="col-xs-2 control-label">現在のパスワード</label>
+        <div class="col-xs-6">
+          <input class="form-control" type="password" name="mePassword[oldPassword]">
+        </div>
+      </div>
+      {% endif %}
+      <div class="form-group {% if not user.password %}has-error{% endif %}">
+        <label for="mePassword[newPassword]" class="col-xs-2 control-label">新しいパスワード</label>
+        <div class="col-xs-6">
+          <input class="form-control" type="password" name="mePassword[newPassword]" required>
+        </div>
+      </div>
+      <div class="form-group">
+        <label for="mePassword[newPasswordConfirm]" class="col-xs-2 control-label">確認</label>
+        <div class="col-xs-6">
+          <input class="form-control col-xs-4" type="password" name="mePassword[newPasswordConfirm]" required>
+
+          <p class="help-block">パスワードには、6文字以上の半角英数字または記号等を設定してください。</p>
+        </div>
+      </div>
+
+
+      <div class="form-group">
+        <div class="col-xs-offset-2 col-xs-10">
+          <button type="submit" class="btn btn-primary">更新</button>
+        </div>
+      </div>
+
+    </fieldset>
+    </form>
+  </div>
+
+
+  </div>
+</div>
+{% endblock content_main %}
+
+{% block content_footer %}
+{% endblock %}
+
+{% block footer %}
+{% endblock %}

+ 113 - 0
views/modal/widget_help.html

@@ -0,0 +1,113 @@
+<div class="modal fade" id="helpModal">
+  <div class="modal-dialog">
+    <div class="modal-content">
+
+      <div class="modal-header">
+        <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+        <h4 class="modal-title">ヘルプ</h4>
+      </div>
+      <div class="modal-body">
+        <h4>基本的な機能</h4>
+        <br>
+        <ul>
+          <li>表示される画面には、「一覧ページ」と「ページ」の2種類があります</li>
+          <li>スラッシュ <code>/</code> で終わるページは、その階層の一覧ページとなります。</li>
+          <li>ページでの変更はすべて記録されています。サイドバーには、変更の履歴が一覧となっていて、クリックするとそのページの過去の状態を見ることができます。</li>
+        </ul>
+        <br>
+
+        <h4>編集のコツ</h4>
+        <br>
+        <p>
+        文章の <strong>構造</strong> を意識しましょう。本を書くように、内容と文脈を整理してセクション・サブセクション...と構造的に書くと、わかりやすく他人に伝わりやすいページがになります。
+        </p>
+        <br>
+
+        <h4>記法</h4>
+        <br>
+        <div class="wiki">
+        <pre># セクション</pre>
+        <h1>セクション</h1>
+        </div>
+        <hr>
+
+        <div class="wiki">
+        <pre>## サブセクション</pre>
+        <h2>サブセクション</h2>
+        </div>
+        <hr>
+
+        <div class="wiki">
+        <pre>### サブサブセクション</pre>
+        <h3>サブサブセクション</h3>
+        </div>
+        <hr>
+
+        <div class="wiki">
+        <pre>* このようにアスタリスクと半角スペースを先頭に書くと、
+* 箇条書きのリストにになります
+    * タブキーを押すと半角スペース4つが挿入され、インデントされます
+    * インデントはリストにも反映されます</pre>
+          <ul>
+            <li>リスト記法はこのように</li>
+            <li>箇条書きになります
+            <ul>
+              <li>タブキーを押すと半角スペース4つが挿入され、インデントされます</li>
+              <li>インデントはリストにも反映されます</li>
+            </ul>
+            </li>
+          </ul>
+        </div>
+        <hr>
+
+        <div class="wiki">
+        <pre>1. 番号付きリストも作れます
+2. "数字" "ドット" "半角スペース" の後に項目を記載しましょう
+2. "1." "2." "2." などと数字がズレても、正しい数字で整形されます</pre>
+          <ol>
+            <li>番号付きリストも作れます</li>
+            <li>"数字" "ドット" "半角スペース" の後に項目を記載しましょう</li>
+            <li>"1." "2." "2." などと数字がズレても、正しい数字で整形されます</li>
+          </ol>
+        </div>
+        <hr>
+
+        <div class="wiki">
+        <pre>**アスタリスク2つで囲った箇所は** 太字になります</pre>
+        <p><strong>アスタリスク2つで囲った箇所は</strong> 太字になります</p>
+        </div>
+        <hr>
+
+        <div class="wiki">
+        <pre>Wiki内リンクは &lt;/とある/ページへの/リンク&gt; のように、 &lt; と &gt; で囲います</pre>
+        <p>Wiki内リンクは <a href="/とある/ページへの/リンク">/とある/ページへの/リンク</a> のように、 &lt; と &gt; で囲います</p>
+        </div>
+        <hr>
+
+        <h4>プレゼンモード</h4>
+        <br>
+        <p>
+        文章をきちんと構造的に作成していれば、自然とプレゼンモードが適用できます。ページの <code><i class="fa"></i></code> アイコンをクリックし、プレゼンモードを選択してください。
+        </p>
+        <p>
+        プレゼンモードでは、<strong>改行2つ</strong> をページ区切りとして利用します。プレゼンモードでページを区切りたい場合、2つの改行を挿入してください。
+        </p>
+
+        <p>例:</p>
+        <div class="wiki">
+          <pre># 改行したいプレゼンの説明
+
+何かしらの説明
+
+
+## 次の章
+
+ここでは、「次の章」の前に2つの改行があるため、「次の章」以降が1つのプレゼンページになります。 </pre>
+        </div>
+        <br>
+
+      </div>
+
+    </div><!-- /.modal-content -->
+  </div><!-- /.modal-dialog -->
+</div><!-- /.modal -->

+ 47 - 0
views/modal/widget_rename.html

@@ -0,0 +1,47 @@
+  <div class="modal fade" id="renamePage">
+    <div class="modal-dialog">
+      <div class="modal-content">
+
+      <form role="form" id="renamePageForm" onsubmit="return false;">
+
+        <div class="modal-header">
+          <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+          <h4 class="modal-title">ページを移動する</h4>
+        </div>
+        <div class="modal-body">
+          <ul>
+           <li>移動先にページが存在する場合は、移動できません。</li>
+           <li>過去の履歴も含めてすべて移動されます。</li>
+          </ul>
+            <div class="form-group">
+              <label for="">このページ</label><br>
+              <code>{{ page.path }}</code>
+            </div>
+            <div class="form-group">
+              <label for="newPageName">移動先のページ名</label><br>
+              <input type="text" class="form-control" name="newPageName" id="newPageName" value="{{ page.path }}">
+            </div>
+            <div class="checkbox">
+               <label>
+                 <input name="createRedirectPage" value="1"  type="checkbox"> リダイレクトページを作成
+               </label>
+               <p class="help-block">チェックを入れると、<code>{{ page.path }}</code>にアクセスされた際に自動的に新しいページにジャンプします。</p>
+            </div>
+            {# <div class="checkbox"> #}
+            {#    <label> #}
+            {#      <input name="moveUnderTrees" value="1" type="checkbox"> 下層ページも全部移動する #}
+            {#    </label> #}
+            {#    <p class="help-block">チェックを入れると、<code>{{ page.path }}</code>以下の階層以下もすべて移動します。</p> #}
+            {#    <p class="help-block">例: <code>/hoge/fuga/move</code> を <code>/foo/bar/move</code> に移動すると、<code>/hoge/fuga/move/page1</code> も <code>/foo/bar/move/page1</code> に。</p> #}
+            {# </div> #}
+        </div>
+        <div class="modal-footer">
+          <p><small class="pull-left" id="newPageNameCheck"></small></p>
+          <input type="hidden" name="previousRevision" value="{{ page.revision._id.toString() }}">
+          <input type="submit" class="btn btn-primary" value="実行">
+        </div>
+
+      </form>
+      </div><!-- /.modal-content -->
+    </div><!-- /.modal-dialog -->
+  </div><!-- /.modal -->

+ 28 - 0
views/modal/widget_today_memo.html

@@ -0,0 +1,28 @@
+
+<div class="modal fade" id="createMemo">
+  <div class="modal-dialog">
+    <div class="modal-content">
+      <form role="form" class="form-inline" id="createMemoForm">
+
+      <div class="modal-header">
+        <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+        <h4 class="modal-title">新規メモを作成</h4>
+      </div>
+      <div class="modal-body">
+        <p><a href="{{ user_page_root(user) }}/メモ/{{ now|datetz('Y/m/d') }}/"><i class="fa fa-list-ul"></i> 今日のメモ一覧を見る</a></p>
+
+          <div class="input-group">
+            <span class="input-group-addon">{{ user_page_root(user) }}/メモ/{{ now|datetz('Y/m/d') }}/</span>
+            <input type="text" class="form-control" id="memoName" name="memoName" placeholder="メモ名を入力">
+          </div>
+          <input type="hidden" class="form-control" name="memoNamePrefix" value="{{ user_page_root(user) }}/メモ/{{ now|datetz('Y/m/d') }}/">
+      </div>
+      <div class="modal-footer">
+        <button type="button" class="btn btn-default" data-dismiss="modal">キャンセル</button>
+        <input type="submit" class="btn btn-primary" id="createMemoSubmit" value="作成">
+      </div>
+
+      </form>
+    </div><!-- /.modal-content -->
+  </div><!-- /.modal-dialog -->
+</div><!-- /.modal -->

+ 327 - 0
views/page.html

@@ -0,0 +1,327 @@
+{% extends 'layout/2column.html' %}
+
+{% block html_title %}{{ path|path2name }} · {{ path }}{% endblock %}
+
+{% block content_head %}
+  <header data-spy="affix" data-offset-top="80" id="page-header">
+    <p class="stopper"><a href="#" data-affix-disable="#page-header"><i class="fa fa-chevron-up"></i></a></p>
+
+    <h1 class="title" id="revision-path">{{ path }}</h1>
+  </header>
+{% endblock %}
+
+{% block content_main %}
+<div class="content-main">
+
+  {% if not page %}
+    <h2>ページを作成する</h2>
+
+    {% include '_form.html' %}
+
+  {% else %}
+
+  <ul class="nav nav-tabs hidden-print">
+
+    <li class=" {% if not req.body.pageForm %}active{% endif %}" data-toggle="tooltip" {# data-title="あなたの 確認待ち です" title="" data-placement="bottom" data-trigger="manual" data-tooltip-stay #}>
+      <a href="#revision-body" data-toggle="tab">
+      <i class="fa fa-magic"></i>
+      {#
+        <img src="//graph.facebook.com/588883490/picture?size=square" width="16"> <i class="fa fa-arrow-right"></i> <img src="//graph.facebook.com/588883490/picture?size=square" width="16">
+        <span class="label label-danger" style=""> 承認待ち</span>
+      #}
+      </a>
+    </li>
+
+    <li><a href="#raw-text" data-toggle="tab"><i class="fa fa-font"></i> テキスト表示</a></li>
+    <li {% if req.body.pageForm %}class="active"{% endif %}><a href="#edit-form" data-toggle="tab"><i class="fa fa-pencil-square-o"></i> 編集</a></li>
+
+    <li class="dropdown pull-right">
+      <a class="dropdown-toggle" data-toggle="dropdown" href="#">
+        <i class="fa fa-wrench"></i> <span class="caret"></span>
+      </a>
+      <ul class="dropdown-menu">
+       <li><a href="#" data-target="#renamePage" data-toggle="modal"><i class="fa fa-share"></i> 移動</a></li>
+       <li><a href="?presentation=1" class="toggle-presentation"><i class="fa fa-arrows-alt"></i> プレゼンモード (beta)</a></li>
+      </ul>
+    </li>
+
+  </ul>
+
+  {% include 'modal/widget_rename.html' %}
+
+
+  <div class="tab-content wiki-content">
+  {% if req.query.renamed %}
+  <div class="alert alert-info">
+    <strong>移動しました: </strong> このページは <code>{{ req.query.renamed }}</code> から移動しました。
+  </div>
+  {% endif %}
+  {% if not page.isLatestRevision() %}
+  <div class="alert alert-warning">
+    <strong>注意: </strong> これは現在の版ではありません。
+  </div>
+  {% endif %}
+
+{#
+  <div class="panel panel-default">
+    <div class="panel-heading">承認待ち</div>
+    <div class="panel-body">
+      ほげほげ
+    </div>
+  </div>
+#}
+
+    {# formatted text #}
+    <div class="tab-pane {% if not req.body.pageForm %}active{% endif %}" id="revision-body">
+      <div class="revision-toc" id="revision-toc">
+        <a data-toggle="collapse" data-parent="#revision-toc" href="#revision-toc-content" class="revision-toc-head collapsed">目次</a>
+
+      </div>
+    {% if revision.format == 'text' %}
+      <div class="wiki {{ revision.format }}" id="revision-body-content"><pre class="" id=""></pre></div>
+    {% else  %}
+      <div class="wiki {{ revision.format }}" id="revision-body-content"></div>
+    {% endif  %}
+    </div>
+
+    {# raw text #}
+    <div class="tab-pane" id="raw-text">
+      <pre id="raw-text-original">{{ revision.body }}</pre>
+    </div>
+
+    {# edit form #}
+    <div class="tab-pane {% if req.body.pageForm %}active{% endif %}" id="edit-form">
+      {% include '_form.html' %}
+    </div>
+  </div>
+  <script type="text/javascript">
+    $(function(){
+        var renderer = new Crowi.renderer('#raw-text-original', '{{ revision.format }}');
+        renderer.render();
+        Crowi.correctHeaders('#revision-body-content');
+        Crowi.revisionToc('#revision-body-content', '#revision-toc');
+
+        $('#edit-form').submit(function()
+        {
+          //console.log('save');
+          //return false;
+        });
+
+        var topMargin = $('#page-header').outerHeight() + 20;
+        $('#page-header').on('affixed.bs.affix', function(e) {
+          $('.content-main').css({'padding-top': topMargin});
+        });
+        $('#page-header').on('affixed-top.bs.affix', function(e) {
+          $('.content-main').css({'padding-top': 0});
+        });
+        $('[data-affix-disable]').on('click', function(e) {
+          $elm = $($(this).data('affix-disable'));
+          $elm.removeClass('affix')
+            .addClass('affix-top')
+            .removeAttr('data-spy');
+          $('.content-main').css({'padding-top': 0});
+
+          return false;
+        });
+    });
+  </script>
+  {% endif %}
+</div>
+
+{% endblock %}
+
+{% block content_footer %}
+<footer>
+  {% if not page %}
+  {% else %}
+  <p class="meta">
+  Path: <span id="pagePath">{{ page.path }}</span><br />
+  Revision: {{ revision._id.toString() }}<br />
+  {% if author %}
+  Last Updated User: <a href="/user/{{ author.username }}">{{ author.name }}</a><br />
+  {% endif %}
+  Created: {{ page.createdAt|datetz('Y-m-d H:i:s') }}<br />
+  Updated: {{ page.updatedAt|datetz('Y-m-d H:i:s') }}<br />
+  </p>
+  {% endif %}
+</footer>
+
+{% endblock %}
+
+{% block side_header %}
+<div class="page-meta">
+  <div class="row">
+    <div class="col-md-3 creator-picture">
+      <img src="{{ user|picture }}" class="picture picture-lg picture-rounded"><br>
+    </div>
+    <div class="col-md-9">
+      <p class="creator">
+        {{ user.name }}
+      </p>
+      <p class="created-at">
+        作成日: {{ page.createdAt|datetz('Y/m/d H:i:s') }}<br>
+        最終更新: {{ page.updatedAt|datetz('Y/m/d H:i:s') }} <a href="/user/{{ author.username }}"><img src="{{ author|picture }}" class="picture picture-xs picture-rounded" alt="{{ author.name }}"></a>
+      </p>
+    </div>
+  </div>
+
+  <div class="like-box">
+    <dl class="dl-horizontal">
+      <dt>
+        <i class="fa fa-star"></i> お気に入り
+      </dt>
+      <dd>
+        <button class="btn btn-default btn-sm btn-bookmark" id="bookmarkButton"><i class="fa fa-star-o"></i></button>
+      </dd>
+
+      <dt>
+        <i class="fa fa-thumbs-o-up"></i> いいね!
+      </dt>
+      <dd>
+        <p class="liker-count">
+        {{ page.liker.length }}
+        </p>
+        <p class="liker-list">
+          {% for liker in page.liker %}
+            <a href="{{ user_page_root(liker) }}" title="{{ liker.name }}"><img alt="{{ liker.name }}" src="{{ liker|picture }}" class="picture picture-xs picture-rounded"></a>
+          {% endfor %}
+          {% if page.liker.length > 10 %}
+            (...)
+          {% endif %}
+        </p>
+        {% if page.isLiked(user) %}
+          <button data-liked="1" class="btn btn-default btn-sm active" id="pageLikeButton"><i class="fa fa-thumbs-up"></i> いいね!!!</button>
+        {% else %}
+          <button data-liked="0" class="btn btn-default btn-sm" id="pageLikeButton"><i class="fa fa-thumbs-o-up"></i> いいね!!!</button>
+        {% endif %}
+      </dd>
+
+      <dt><i class="fa fa-eye"></i> 見た人</dt>
+      <dd>
+        <p class="seen-user-count">
+          {{ page.seenUsers.length }}
+        </p>
+        <p class="seen-user-list">
+          {% for seenUser in page.seenUsers %}
+          <a href="{{ user_page_root(seenUser) }}" title="{{ seenUser.name }}"><img alt="{{ seenUser.name }}" src="{{ seenUser|picture }}" class="picture picture-xs picture-rounded"></a>
+          {% endfor %}
+          {% if page.seenUsers.length > 10 %}
+            (...)
+          {% endif %}
+        </p>
+      </dd>
+    </dl>
+  </div>
+<script>
+$(function() {
+  $.get('/_api/page/{{ page._id.toString() }}/bookmark', function(data) {
+    if (data.bookmarked) {
+      $('#bookmarkButton')
+        .removeClass('btn-default')
+        .addClass('btn-warning active bookmarked');
+      $('#bookmarkButton i')
+        .removeClass('fa-star-o')
+        .addClass('fa-star');
+    }
+  });
+
+  $('#bookmarkButton').click(function() {
+    var pageId = {{page._id|json|safe}};
+    $.post('/_api/page/{{ page._id.toString() }}/bookmark', function(data) {
+      console.log(data);
+    });
+  });
+  $('#pageLikeButton').click(function() {
+    var pageId = {{page._id|json|safe}};
+    $.post('/_api/page/{{ page._id.toString() }}/like', function(data) {
+      console.log(data);
+    });
+  });
+});
+</script>
+</div>
+{% endblock %} {# side_header #}
+
+{% block side_content %}
+
+  <h3><i class="fa fa-link"></i> 共有</h3>
+  <ul class="fitted-list">
+    <li data-toggle="tooltip" data-placement="bottom" title="共有用リンク" class="input-group">
+      <span class="input-group-addon">共有用</span>
+      <input class="copy-link form-control" type="text" value="{{ config.app.title }} {{ path }}  {{ baseUrl }}/_r/{{ page._id.toString() }}">
+    </li>
+    <li data-toggle="tooltip" data-placement="bottom" title="Wiki記法" class="input-group">
+      <span class="input-group-addon">Wikiタグ</span>
+      <input class="copy-link form-control" type="text" value="&lt;{{ path }}&gt;">
+    </li>
+    <li data-toggle="tooltip" data-placement="bottom" title="Markdown形式のリンク" class="input-group">
+      <span class="input-group-addon">Markdown</span>
+      <input class="copy-link form-control" type="text" value="[{{ path }}]({{ baseUrl }}/_r/{{ revision._id.toString() }})">
+    </li>
+  </ul>
+
+  <h3><i class="fa fa-history"></i> History</h3>
+  {% if not page %}
+  {% else %}
+  <ul class="revision-history">
+    {% for t in tree %}
+    <li>
+      <a href="?revision={{ t._id.toString() }}">
+        <img src="{{ t.author|picture }}" class="picture picture-rounded">
+        {% if t.author %}{{ t.author.username }}{% else %}-{% endif %}<br>{{ t.createdAt|datetz('Y-m-d H:i:s') }}
+      </a>
+    </li>
+    {% endfor %}
+  </ul>
+  {% endif %}
+{% endblock %}
+
+{% block footer %}
+<div id="notifPageEdited" class="fk-hide fk-notif fk-notif-danger"><i class="fa fa-exclamation-triangle"></i> <span class="edited-user"></span>さんがこのページを編集しました。 <a href="javascript:location.reload();"><i class="fa fa-angle-double-right"></i> 最新版を読み込む</a></div>
+<div id="notifPageEditing" class="fk-hide fk-notif fk-notif-warning"><i class="fa fa-exclamation-triangle"></i> 他の人がこのページの編集を開始しました。</div>
+
+<script>
+  $(function() {
+    var me = {{ user|json|safe }};
+    var socket = io.connect('{{ baseUrl }}');
+    socket.on('page edited', function (data) {
+      if (data.user._id != me._id
+        && data.page.path == {{ page.path|json|safe }}) {
+        $('#notifPageEdited').removeClass('fk-hide').css({bottom: 0});
+        $('#notifPageEdited .edited-user').html(data.user.name);
+      }
+    });
+  });
+</script>
+{% endblock %}
+
+{% block body_end %}
+  {% parent %}
+
+  <div id="presentation-layer" class="fullscreen-layer">
+    <div id="presentation-container"></div>
+  </div>
+  <script>
+    $(function() {
+      var presentaionInitialized = false
+        , $b = $('body');
+
+      $(document).on('click', '.toggle-presentation', function(e) {
+        var $a = $(this);
+
+        e.preventDefault();
+        $b.toggleClass('overlay-on');
+
+        if (!presentaionInitialized) {
+          presentaionInitialized = true;
+
+          $('<iframe />').attr({
+            src: $a.attr('href')
+          }).appendTo($('#presentation-container'));
+        }
+      }).on('click', '.fullscreen-layer', function() {
+        $b.toggleClass('overlay-on');
+      });
+    });
+  </script>
+{% endblock %}

+ 81 - 0
views/page_list.html

@@ -0,0 +1,81 @@
+{% extends 'layout/2column.html' %}
+
+{% block content_head %}
+  <header>
+  <h1 class="title" id="revision-path">{{ path }}</h1>
+  </header>
+{% endblock %}
+
+{% block content_main %}
+<div class="content-main">
+
+<ul class="nav nav-tabs">
+    <li class="active"><a href="#view-list" data-toggle="tab">リスト表示</a></li>
+    <li><a href="#view-timeline" data-toggle="tab">タイムライン表示</a></li>
+</ul>
+
+<h2>ページ一覧</h2>
+  <div class="tab-content">
+    {# list view #}
+    <div class="active wiki tab-pane fade in" id="view-list">
+      {% for page in pages %}
+        <a href="{{ page.path }}">{{ page.path }}</a>
+
+        {% if !page.isPublic() %}
+          <i class="fa fa-lock"></i>
+        {% endif %}
+        <br />
+      {% endfor %}
+
+        <ul class="pagination">
+          {% if pager.prev != null %}
+            <li class="prev"><a href="{{ path }}?offset={{ pager.prev }}&limit={{ pager.limit }}"><i class="fa fa-arrow-left"></i> Prev</a></li>
+          {% endif %}
+          {# この条件は無いな.. #}
+          {% if pages.length > 0 %}
+            <li class="next"><a href="{{ path }}?offset={{ pager.next }}&limit={{ pager.limit }}">Next <i class="fa fa-arrow-right"></i></a></li>
+          {% endif %}
+        </ul>
+    </div>
+
+    {# timeline view #}
+    <div class="tab-pane" id="view-timeline">
+      {% for page in pages %}
+      <div class="timeline-body" id="id-{{ page.id }}">
+        <h3 class="revision-path"><a href="{{ page.path }}">{{ page.path }}</a></h3>
+        <div class="revision-body" data-format="{{ page.revision.format }}"><pre></pre></div>
+        <pre class="hide raw-text-original">{{ page.revision.body }}</pre>
+      </div>
+      {% endfor %}
+    </div>
+  </div>
+
+  <script type="text/javascript">
+    $(function(){
+        $('#view-timeline .timeline-body').each(function()
+        {
+          var id = $(this).attr('id');
+          //var format = $(this).children('.body').data('format');
+          var format = 'text';
+          var contentId = '#' + id + ' .raw-text-original';
+          var revisionBody = '#' + id + ' .revision-body';
+          var revisionPath = '#' + id + ' .revision-path';
+          var renderer = new Crowi.renderer(contentId, format, revisionBody);
+          renderer.render();
+        });
+        //$('.tooltip .tabs').tabs();
+    });
+  </script>
+
+</div> {# /.content-main #}
+
+
+{% endblock %}
+
+
+{% block content_footer %}
+<footer>
+
+</footer>
+{% endblock %}
+

+ 73 - 0
views/page_presentation.html

@@ -0,0 +1,73 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <meta name="apple-mobile-web-app-capable" content="yes" />
+    <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
+
+    <link rel="stylesheet" type="text/css" href="//cdnjs.cloudflare.com/ajax/libs/reveal.js/2.5/css/reveal.min.css">
+    <link rel="stylesheet" type="text/css" href="//cdnjs.cloudflare.com/ajax/libs/reveal.js/2.5/css/theme/solarized.css">
+    <link rel="stylesheet" type="text/css" href="/css/crowi-reveal.css">
+    <link rel="stylesheet" type="text/css" href="//cdnjs.cloudflare.com/ajax/libs/reveal.js/2.5/lib/css/zenburn.css">
+    <title>{{ path|path2name }} | {{ path }}</title>
+  </head>
+  <body>
+    <div class="reveal">
+      <div class="slides">
+        <section data-markdown data-separator="^\n\n\n">
+          <script type="text/template">
+{{ revision.body|presentation }}
+
+
+
+# おしまい
+          </script>
+        </section>
+      </div>
+    </div>
+
+    <script src="//ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js"></script>
+    <script src="//cdnjs.cloudflare.com/ajax/libs/reveal.js/2.5/lib/js/head.min.js"></script>
+    <script src="//cdnjs.cloudflare.com/ajax/libs/reveal.js/2.5/js/reveal.min.js"></script>
+    <script>
+
+      // Full list of configuration options available here:
+      // https://github.com/hakimel/reveal.js#configuration
+      Reveal.initialize({
+        controls: true,
+        progress: true,
+        history: true,
+        center: false,
+
+        theme: Reveal.getQueryHash().theme, // available themes are in /css/theme
+        transition: Reveal.getQueryHash().transition || 'default', // default/cube/page/concave/zoom/linear/fade/none
+
+        // Parallax scrolling
+        // parallaxBackgroundImage: 'https://s3.amazonaws.com/hakim-static/reveal-js/reveal-parallax-1.jpg',
+        // parallaxBackgroundSize: '2100px 900px',
+
+        // Optional libraries used to extend on reveal.js
+        dependencies: [
+          { src: '//cdnjs.cloudflare.com/ajax/libs/reveal.js/2.5/lib/js/classList.js', condition: function() { return !document.body.classList; } },
+          { src: '//cdnjs.cloudflare.com/ajax/libs/reveal.js/2.5/plugin/markdown/marked.js', condition: function() { return !!document.querySelector( '[data-markdown]' ); } },
+          { src: '//cdnjs.cloudflare.com/ajax/libs/reveal.js/2.5/plugin/markdown/markdown.js', condition: function() { return !!document.querySelector( '[data-markdown]' ); } },
+          { src: '//cdnjs.cloudflare.com/ajax/libs/reveal.js/2.5/plugin/highlight/highlight.js', async: true, callback: function() { hljs.initHighlightingOnLoad(); } },
+          { src: '//cdnjs.cloudflare.com/ajax/libs/reveal.js/2.5/plugin/zoom-js/zoom.js', async: true, condition: function() { return !!document.body.classList; } },
+          { src: '//cdnjs.cloudflare.com/ajax/libs/reveal.js/2.5/plugin/notes/notes.js', async: true, condition: function() { return !!document.body.classList; } }
+        ]
+      });
+
+      //
+      Reveal.addEventListener('ready', function(event) {
+        // event.currentSlide, event.indexh, event.indexv
+        $('.reveal section').each(function(e) {
+          var $self = $(this);
+          if ($self.children().length == 1) {
+            $self.addClass('only');
+          }
+        });
+      });
+    </script>
+  </body>
+</html>

+ 2 - 0
views/user_page.html

@@ -0,0 +1,2 @@
+{% extends 'page.html' %}
+