commit e84561898ff6d09320e6c4252fa670183a793720 Author: Adam Brown Date: Fri Feb 6 10:46:55 2015 -0500 initial check-in with working wdiff server-side api diff --git a/.bowerrc b/.bowerrc new file mode 100644 index 0000000..666f347 --- /dev/null +++ b/.bowerrc @@ -0,0 +1,3 @@ +{ + "directory": "client/bower_components" +} diff --git a/.buildignore b/.buildignore new file mode 100644 index 0000000..fc98b8e --- /dev/null +++ b/.buildignore @@ -0,0 +1 @@ +*.coffee \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c2cdfb8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +# EditorConfig helps developers define and maintain consistent +# coding styles between different editors and IDEs +# editorconfig.org + +root = true + + +[*] + +# Change these settings to your own preference +indent_style = space +indent_size = 2 + +# We recommend you to keep these unchanged +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2125666 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..46a00f5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules +public +.tmp +.sass-cache +.idea +client/bower_components +dist +/server/config/local.env.js +wdiff-1.2.2 diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..05c316f --- /dev/null +++ b/.travis.yml @@ -0,0 +1,9 @@ +language: node_js +node_js: + - '0.10' + - '0.11' +before_script: + - npm install -g bower grunt-cli + - gem install sass + - bower install +services: mongodb \ No newline at end of file diff --git a/.yo-rc.json b/.yo-rc.json new file mode 100644 index 0000000..ab929e2 --- /dev/null +++ b/.yo-rc.json @@ -0,0 +1,51 @@ +{ + "generator-angular-fullstack": { + "insertRoutes": true, + "registerRoutesFile": "server/routes.js", + "routesNeedle": "// Insert routes below", + "routesBase": "/api/", + "pluralizeRoutes": true, + "insertSockets": true, + "registerSocketsFile": "server/config/socketio.js", + "socketsNeedle": "// Insert sockets below", + "filters": { + "js": true, + "jade": true, + "sass": true, + "ngroute": true, + "bootstrap": true, + "uibootstrap": true, + "mongoose": true, + "auth": true, + "oauth": true, + "googleAuth": true, + "facebookAuth": true, + "twitterAuth": true + } + }, + "generator-ng-component": { + "routeDirectory": "client/app/", + "directiveDirectory": "client/app/", + "filterDirectory": "client/app/", + "serviceDirectory": "client/app/", + "basePath": "client", + "moduleName": "", + "filters": [ + "ngroute" + ], + "extensions": [ + "js", + "jade", + "scss" + ], + "directiveSimpleTemplates": "", + "directiveComplexTemplates": "", + "filterTemplates": "", + "serviceTemplates": "", + "factoryTemplates": "", + "controllerTemplates": "", + "decoratorTemplates": "", + "providerTemplates": "", + "routeTemplates": "" + } +} \ No newline at end of file diff --git a/Gruntfile.js b/Gruntfile.js new file mode 100644 index 0000000..dae3c4f --- /dev/null +++ b/Gruntfile.js @@ -0,0 +1,696 @@ +// Generated on 2015-02-05 using generator-angular-fullstack 2.0.13 +'use strict'; + +module.exports = function (grunt) { + var localConfig; + try { + localConfig = require('./server/config/local.env'); + } catch(e) { + localConfig = {}; + } + + // Load grunt tasks automatically, when needed + require('jit-grunt')(grunt, { + express: 'grunt-express-server', + useminPrepare: 'grunt-usemin', + ngtemplates: 'grunt-angular-templates', + cdnify: 'grunt-google-cdn', + protractor: 'grunt-protractor-runner', + injector: 'grunt-asset-injector', + buildcontrol: 'grunt-build-control' + }); + + // Time how long tasks take. Can help when optimizing build times + require('time-grunt')(grunt); + + // Define the configuration for all the tasks + grunt.initConfig({ + + // Project settings + pkg: grunt.file.readJSON('package.json'), + yeoman: { + // configurable paths + client: require('./bower.json').appPath || 'client', + dist: 'dist' + }, + express: { + options: { + port: process.env.PORT || 9000 + }, + dev: { + options: { + script: 'server/app.js', + debug: true + } + }, + prod: { + options: { + script: 'dist/server/app.js' + } + } + }, + open: { + server: { + url: 'http://localhost:<%= express.options.port %>' + } + }, + watch: { + injectJS: { + files: [ + '<%= yeoman.client %>/{app,components}/**/*.js', + '!<%= yeoman.client %>/{app,components}/**/*.spec.js', + '!<%= yeoman.client %>/{app,components}/**/*.mock.js', + '!<%= yeoman.client %>/app/app.js'], + tasks: ['injector:scripts'] + }, + injectCss: { + files: [ + '<%= yeoman.client %>/{app,components}/**/*.css' + ], + tasks: ['injector:css'] + }, + mochaTest: { + files: ['server/**/*.spec.js'], + tasks: ['env:test', 'mochaTest'] + }, + jsTest: { + files: [ + '<%= yeoman.client %>/{app,components}/**/*.spec.js', + '<%= yeoman.client %>/{app,components}/**/*.mock.js' + ], + tasks: ['newer:jshint:all', 'karma'] + }, + injectSass: { + files: [ + '<%= yeoman.client %>/{app,components}/**/*.{scss,sass}'], + tasks: ['injector:sass'] + }, + sass: { + files: [ + '<%= yeoman.client %>/{app,components}/**/*.{scss,sass}'], + tasks: ['sass', 'autoprefixer'] + }, + jade: { + files: [ + '<%= yeoman.client %>/{app,components}/*', + '<%= yeoman.client %>/{app,components}/**/*.jade'], + tasks: ['jade'] + }, + gruntfile: { + files: ['Gruntfile.js'] + }, + livereload: { + files: [ + '{.tmp,<%= yeoman.client %>}/{app,components}/**/*.css', + '{.tmp,<%= yeoman.client %>}/{app,components}/**/*.html', + '{.tmp,<%= yeoman.client %>}/{app,components}/**/*.js', + '!{.tmp,<%= yeoman.client %>}{app,components}/**/*.spec.js', + '!{.tmp,<%= yeoman.client %>}/{app,components}/**/*.mock.js', + '<%= yeoman.client %>/assets/images/{,*//*}*.{png,jpg,jpeg,gif,webp,svg}' + ], + options: { + livereload: true + } + }, + express: { + files: [ + 'server/**/*.{js,json}' + ], + tasks: ['express:dev', 'wait'], + options: { + livereload: true, + nospawn: true //Without this option specified express won't be reloaded + } + } + }, + + // Make sure code styles are up to par and there are no obvious mistakes + jshint: { + options: { + jshintrc: '<%= yeoman.client %>/.jshintrc', + reporter: require('jshint-stylish') + }, + server: { + options: { + jshintrc: 'server/.jshintrc' + }, + src: [ + 'server/**/*.js', + '!server/**/*.spec.js' + ] + }, + serverTest: { + options: { + jshintrc: 'server/.jshintrc-spec' + }, + src: ['server/**/*.spec.js'] + }, + all: [ + '<%= yeoman.client %>/{app,components}/**/*.js', + '!<%= yeoman.client %>/{app,components}/**/*.spec.js', + '!<%= yeoman.client %>/{app,components}/**/*.mock.js' + ], + test: { + src: [ + '<%= yeoman.client %>/{app,components}/**/*.spec.js', + '<%= yeoman.client %>/{app,components}/**/*.mock.js' + ] + } + }, + + // Empties folders to start fresh + clean: { + dist: { + files: [{ + dot: true, + src: [ + '.tmp', + '<%= yeoman.dist %>/*', + '!<%= yeoman.dist %>/.git*', + '!<%= yeoman.dist %>/.openshift', + '!<%= yeoman.dist %>/Procfile' + ] + }] + }, + server: '.tmp' + }, + + // Add vendor prefixed styles + autoprefixer: { + options: { + browsers: ['last 1 version'] + }, + dist: { + files: [{ + expand: true, + cwd: '.tmp/', + src: '{,*/}*.css', + dest: '.tmp/' + }] + } + }, + + // Debugging with node inspector + 'node-inspector': { + custom: { + options: { + 'web-host': 'localhost' + } + } + }, + + // Use nodemon to run server in debug mode with an initial breakpoint + nodemon: { + debug: { + script: 'server/app.js', + options: { + nodeArgs: ['--debug-brk'], + env: { + PORT: process.env.PORT || 9000 + }, + callback: function (nodemon) { + nodemon.on('log', function (event) { + console.log(event.colour); + }); + + // opens browser on initial server start + nodemon.on('config:update', function () { + setTimeout(function () { + require('open')('http://localhost:8080/debug?port=5858'); + }, 500); + }); + } + } + } + }, + + // Automatically inject Bower components into the app + wiredep: { + target: { + src: '<%= yeoman.client %>/index.html', + ignorePath: '<%= yeoman.client %>/', + exclude: [/bootstrap-sass-official/, /bootstrap.js/, '/json3/', '/es5-shim/', /bootstrap.css/, /font-awesome.css/ ] + } + }, + + // Renames files for browser caching purposes + rev: { + dist: { + files: { + src: [ + '<%= yeoman.dist %>/public/{,*/}*.js', + '<%= yeoman.dist %>/public/{,*/}*.css', + '<%= yeoman.dist %>/public/assets/images/{,*/}*.{png,jpg,jpeg,gif,webp,svg}', + '<%= yeoman.dist %>/public/assets/fonts/*' + ] + } + } + }, + + // Reads HTML for usemin blocks to enable smart builds that automatically + // concat, minify and revision files. Creates configurations in memory so + // additional tasks can operate on them + useminPrepare: { + html: ['<%= yeoman.client %>/index.html'], + options: { + dest: '<%= yeoman.dist %>/public' + } + }, + + // Performs rewrites based on rev and the useminPrepare configuration + usemin: { + html: ['<%= yeoman.dist %>/public/{,*/}*.html'], + css: ['<%= yeoman.dist %>/public/{,*/}*.css'], + js: ['<%= yeoman.dist %>/public/{,*/}*.js'], + options: { + assetsDirs: [ + '<%= yeoman.dist %>/public', + '<%= yeoman.dist %>/public/assets/images' + ], + // This is so we update image references in our ng-templates + patterns: { + js: [ + [/(assets\/images\/.*?\.(?:gif|jpeg|jpg|png|webp|svg))/gm, 'Update the JS to reference our revved images'] + ] + } + } + }, + + // The following *-min tasks produce minified files in the dist folder + imagemin: { + dist: { + files: [{ + expand: true, + cwd: '<%= yeoman.client %>/assets/images', + src: '{,*/}*.{png,jpg,jpeg,gif}', + dest: '<%= yeoman.dist %>/public/assets/images' + }] + } + }, + + svgmin: { + dist: { + files: [{ + expand: true, + cwd: '<%= yeoman.client %>/assets/images', + src: '{,*/}*.svg', + dest: '<%= yeoman.dist %>/public/assets/images' + }] + } + }, + + // Allow the use of non-minsafe AngularJS files. Automatically makes it + // minsafe compatible so Uglify does not destroy the ng references + ngAnnotate: { + dist: { + files: [{ + expand: true, + cwd: '.tmp/concat', + src: '*/**.js', + dest: '.tmp/concat' + }] + } + }, + + // Package all the html partials into a single javascript payload + ngtemplates: { + options: { + // This should be the name of your apps angular module + module: 'markdownFormatWdiffApp', + htmlmin: { + collapseBooleanAttributes: true, + collapseWhitespace: true, + removeAttributeQuotes: true, + removeEmptyAttributes: true, + removeRedundantAttributes: true, + removeScriptTypeAttributes: true, + removeStyleLinkTypeAttributes: true + }, + usemin: 'app/app.js' + }, + main: { + cwd: '<%= yeoman.client %>', + src: ['{app,components}/**/*.html'], + dest: '.tmp/templates.js' + }, + tmp: { + cwd: '.tmp', + src: ['{app,components}/**/*.html'], + dest: '.tmp/tmp-templates.js' + } + }, + + // Replace Google CDN references + cdnify: { + dist: { + html: ['<%= yeoman.dist %>/public/*.html'] + } + }, + + // Copies remaining files to places other tasks can use + copy: { + dist: { + files: [{ + expand: true, + dot: true, + cwd: '<%= yeoman.client %>', + dest: '<%= yeoman.dist %>/public', + src: [ + '*.{ico,png,txt}', + '.htaccess', + 'bower_components/**/*', + 'assets/images/{,*/}*.{webp}', + 'assets/fonts/**/*', + 'index.html' + ] + }, { + expand: true, + cwd: '.tmp/images', + dest: '<%= yeoman.dist %>/public/assets/images', + src: ['generated/*'] + }, { + expand: true, + dest: '<%= yeoman.dist %>', + src: [ + 'package.json', + 'server/**/*' + ] + }] + }, + styles: { + expand: true, + cwd: '<%= yeoman.client %>', + dest: '.tmp/', + src: ['{app,components}/**/*.css'] + } + }, + + buildcontrol: { + options: { + dir: 'dist', + commit: true, + push: true, + connectCommits: false, + message: 'Built %sourceName% from commit %sourceCommit% on branch %sourceBranch%' + }, + heroku: { + options: { + remote: 'heroku', + branch: 'master' + } + }, + openshift: { + options: { + remote: 'openshift', + branch: 'master' + } + } + }, + + // Run some tasks in parallel to speed up the build process + concurrent: { + server: [ + 'jade', + 'sass', + ], + test: [ + 'jade', + 'sass', + ], + debug: { + tasks: [ + 'nodemon', + 'node-inspector' + ], + options: { + logConcurrentOutput: true + } + }, + dist: [ + 'jade', + 'sass', + 'imagemin', + 'svgmin' + ] + }, + + // Test settings + karma: { + unit: { + configFile: 'karma.conf.js', + singleRun: true + } + }, + + mochaTest: { + options: { + reporter: 'spec' + }, + src: ['server/**/*.spec.js'] + }, + + protractor: { + options: { + configFile: 'protractor.conf.js' + }, + chrome: { + options: { + args: { + browser: 'chrome' + } + } + } + }, + + env: { + test: { + NODE_ENV: 'test' + }, + prod: { + NODE_ENV: 'production' + }, + all: localConfig + }, + + // Compiles Jade to html + jade: { + compile: { + options: { + data: { + debug: false + } + }, + files: [{ + expand: true, + cwd: '<%= yeoman.client %>', + src: [ + '{app,components}/**/*.jade' + ], + dest: '.tmp', + ext: '.html' + }] + } + }, + + // Compiles Sass to CSS + sass: { + server: { + options: { + loadPath: [ + '<%= yeoman.client %>/bower_components', + '<%= yeoman.client %>/app', + '<%= yeoman.client %>/components' + ], + compass: false + }, + files: { + '.tmp/app/app.css' : '<%= yeoman.client %>/app/app.scss' + } + } + }, + + injector: { + options: { + + }, + // Inject application script files into index.html (doesn't include bower) + scripts: { + options: { + transform: function(filePath) { + filePath = filePath.replace('/client/', ''); + filePath = filePath.replace('/.tmp/', ''); + return ''; + }, + starttag: '', + endtag: '' + }, + files: { + '<%= yeoman.client %>/index.html': [ + ['{.tmp,<%= yeoman.client %>}/{app,components}/**/*.js', + '!{.tmp,<%= yeoman.client %>}/app/app.js', + '!{.tmp,<%= yeoman.client %>}/{app,components}/**/*.spec.js', + '!{.tmp,<%= yeoman.client %>}/{app,components}/**/*.mock.js'] + ] + } + }, + + // Inject component scss into app.scss + sass: { + options: { + transform: function(filePath) { + filePath = filePath.replace('/client/app/', ''); + filePath = filePath.replace('/client/components/', ''); + return '@import \'' + filePath + '\';'; + }, + starttag: '// injector', + endtag: '// endinjector' + }, + files: { + '<%= yeoman.client %>/app/app.scss': [ + '<%= yeoman.client %>/{app,components}/**/*.{scss,sass}', + '!<%= yeoman.client %>/app/app.{scss,sass}' + ] + } + }, + + // Inject component css into index.html + css: { + options: { + transform: function(filePath) { + filePath = filePath.replace('/client/', ''); + filePath = filePath.replace('/.tmp/', ''); + return ''; + }, + starttag: '', + endtag: '' + }, + files: { + '<%= yeoman.client %>/index.html': [ + '<%= yeoman.client %>/{app,components}/**/*.css' + ] + } + } + }, + }); + + // Used for delaying livereload until after server has restarted + grunt.registerTask('wait', function () { + grunt.log.ok('Waiting for server reload...'); + + var done = this.async(); + + setTimeout(function () { + grunt.log.writeln('Done waiting!'); + done(); + }, 1500); + }); + + grunt.registerTask('express-keepalive', 'Keep grunt running', function() { + this.async(); + }); + + grunt.registerTask('serve', function (target) { + if (target === 'dist') { + return grunt.task.run(['build', 'env:all', 'env:prod', 'express:prod', 'wait', 'open', 'express-keepalive']); + } + + if (target === 'debug') { + return grunt.task.run([ + 'clean:server', + 'env:all', + 'injector:sass', + 'concurrent:server', + 'injector', + 'wiredep', + 'autoprefixer', + 'concurrent:debug' + ]); + } + + grunt.task.run([ + 'clean:server', + 'env:all', + 'injector:sass', + 'concurrent:server', + 'injector', + 'wiredep', + 'autoprefixer', + 'express:dev', + 'wait', + 'open', + 'watch' + ]); + }); + + grunt.registerTask('server', function () { + grunt.log.warn('The `server` task has been deprecated. Use `grunt serve` to start a server.'); + grunt.task.run(['serve']); + }); + + grunt.registerTask('test', function(target) { + if (target === 'server') { + return grunt.task.run([ + 'env:all', + 'env:test', + 'mochaTest' + ]); + } + + else if (target === 'client') { + return grunt.task.run([ + 'clean:server', + 'env:all', + 'injector:sass', + 'concurrent:test', + 'injector', + 'autoprefixer', + 'karma' + ]); + } + + else if (target === 'e2e') { + return grunt.task.run([ + 'clean:server', + 'env:all', + 'env:test', + 'injector:sass', + 'concurrent:test', + 'injector', + 'wiredep', + 'autoprefixer', + 'express:dev', + 'protractor' + ]); + } + + else grunt.task.run([ + 'test:server', + 'test:client' + ]); + }); + + grunt.registerTask('build', [ + 'clean:dist', + 'injector:sass', + 'concurrent:dist', + 'injector', + 'wiredep', + 'useminPrepare', + 'autoprefixer', + 'ngtemplates', + 'concat', + 'ngAnnotate', + 'copy:dist', + 'cdnify', + 'cssmin', + 'uglify', + 'rev', + 'usemin' + ]); + + grunt.registerTask('default', [ + 'newer:jshint', + 'test', + 'build' + ]); +}; diff --git a/bin/markdown-format-wdiff b/bin/markdown-format-wdiff new file mode 100644 index 0000000..28d045e --- /dev/null +++ b/bin/markdown-format-wdiff @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Markdown-wdiff -- format diff of Markdown files with decoration +# Usage: +# wdiff old.md new.md | markdown-format-wdiff +# git diff --word-diff origin/master -- README.md docs/tutorial/README.md | markdown-format-wdiff +# +# Author: Jaeho Shin +# Created: 2013-11-18 +set -eu + +# word diff the given unified diff as input, and format it by hunks +sed ' +# format ins/del of words +s|\[-||g; s|-]||g +s|{+||g; s|+}||g +' + +# attach a small stylesheet +#echo ' +# +#' diff --git a/bin/markdown-git-changes b/bin/markdown-git-changes new file mode 100644 index 0000000..0d8d92c --- /dev/null +++ b/bin/markdown-git-changes @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Summarize commit history and diff of Markdown files in the current Git repo +# Usage: +# markdown-git-changes [COMMIT [MARKDOWN_FILE]...] +# +# You can specify COMMIT as `--' to automatically see changes of given files +# against the upstream branch, HEAD^, or HEAD. +# +# Author: Jaeho Shin +# Created: 2013-11-18 +set -eu + +Here=$(dirname "$0") +PATH="$Here:$PATH" # add this directory to PATH for markdown-format-wdiff + +# TODO passthru any diff options + +# how to find markdown files in the current git repo +files=() +find_markdown_files() { + local IFS=$'\n' + set -- $(git ls-files | grep '\.\(md\|mkd\|markdn\|markdown\)$') + files=("$@") +} + +# diff against given commit, or upstream branch, or HEAD^ if no local changes, or HEAD +since=--; [ $# -eq 0 ] || { since=$1; shift; } +if [ x"$since" = x"--" ]; then + find_markdown_files + since=` + git rev-parse --abbrev-ref HEAD@{upstream} 2>/dev/null || + case $(git status --porcelain --untracked-files=no "${files[@]}" | wc -l) in + 0) echo 'HEAD^' ;; + *) echo 'HEAD' + esac + ` +fi +# unless list of files are given, find Markdown documents in the Git repo +if [ $# -gt 0 ]; then + files=("$@") +elif [ ${#files[@]} -eq 0 ]; then + find_markdown_files +fi + +# make sure ${files[@]} isn't empty +[ ${#files[@]} -gt 0 ] || files+=(.) + + +# summarize commit history +{ + echo "\$ git log --oneline "$since"..HEAD $*" + git log --oneline "$since"..HEAD "${files[@]}" +} | +sed 's/^/ /' +echo + +# and embed the overall word diff +git diff ${GIT_DIFF_OPTS:-} --word-diff --patch-with-stat \ + --minimal --patience \ + "$since" -- "${files[@]}" | +sed ' + # format prologue + 1,/^diff /{ + /^diff/! s/^/ / + } + + # format file headers + /^diff /,/^+++ /{ + /^diff /{ + s|^diff .* \([^/]/\)\(.*\)|
\2
| + a\ + \ + + } + /^
/! s/^/ / + } + + # format hunks + /^@@ -.* +.* @@/{ + s| @@.*| @@| + s|^|
| + s|$|
| + } +' | +markdown-format-wdiff +echo ' + +' diff --git a/bin/wdiff b/bin/wdiff new file mode 120000 index 0000000..33ccc5b --- /dev/null +++ b/bin/wdiff @@ -0,0 +1 @@ +../wdiff-1.2.2/src/wdiff \ No newline at end of file diff --git a/bower.json b/bower.json new file mode 100644 index 0000000..561edd9 --- /dev/null +++ b/bower.json @@ -0,0 +1,24 @@ +{ + "name": "markdown-format-wdiff", + "version": "0.0.0", + "dependencies": { + "angular": ">=1.2.*", + "json3": "~3.3.1", + "es5-shim": "~3.0.1", + "jquery": "~1.11.0", + "bootstrap-sass-official": "~3.1.1", + "bootstrap": "~3.1.1", + "angular-resource": ">=1.2.*", + "angular-cookies": ">=1.2.*", + "angular-sanitize": ">=1.2.*", + "angular-route": ">=1.2.*", + "angular-bootstrap": "~0.11.0", + "font-awesome": ">=4.1.0", + "lodash": "~2.4.1", + "angular-markdown-directive": "~0.3.1" + }, + "devDependencies": { + "angular-mocks": ">=1.2.*", + "angular-scenario": ">=1.2.*" + } +} diff --git a/client/.htaccess b/client/.htaccess new file mode 100644 index 0000000..cb84cb9 --- /dev/null +++ b/client/.htaccess @@ -0,0 +1,543 @@ +# Apache Configuration File + +# (!) Using `.htaccess` files slows down Apache, therefore, if you have access +# to the main server config file (usually called `httpd.conf`), you should add +# this logic there: http://httpd.apache.org/docs/current/howto/htaccess.html. + +# ############################################################################## +# # CROSS-ORIGIN RESOURCE SHARING (CORS) # +# ############################################################################## + +# ------------------------------------------------------------------------------ +# | Cross-domain AJAX requests | +# ------------------------------------------------------------------------------ + +# Enable cross-origin AJAX requests. +# http://code.google.com/p/html5security/wiki/CrossOriginRequestSecurity +# http://enable-cors.org/ + +# +# Header set Access-Control-Allow-Origin "*" +# + +# ------------------------------------------------------------------------------ +# | CORS-enabled images | +# ------------------------------------------------------------------------------ + +# Send the CORS header for images when browsers request it. +# https://developer.mozilla.org/en/CORS_Enabled_Image +# http://blog.chromium.org/2011/07/using-cross-domain-images-in-webgl-and.html +# http://hacks.mozilla.org/2011/11/using-cors-to-load-webgl-textures-from-cross-domain-images/ + + + + + SetEnvIf Origin ":" IS_CORS + Header set Access-Control-Allow-Origin "*" env=IS_CORS + + + + +# ------------------------------------------------------------------------------ +# | Web fonts access | +# ------------------------------------------------------------------------------ + +# Allow access from all domains for web fonts + + + + Header set Access-Control-Allow-Origin "*" + + + + +# ############################################################################## +# # ERRORS # +# ############################################################################## + +# ------------------------------------------------------------------------------ +# | 404 error prevention for non-existing redirected folders | +# ------------------------------------------------------------------------------ + +# Prevent Apache from returning a 404 error for a rewrite if a directory +# with the same name does not exist. +# http://httpd.apache.org/docs/current/content-negotiation.html#multiviews +# http://www.webmasterworld.com/apache/3808792.htm + +Options -MultiViews + +# ------------------------------------------------------------------------------ +# | Custom error messages / pages | +# ------------------------------------------------------------------------------ + +# You can customize what Apache returns to the client in case of an error (see +# http://httpd.apache.org/docs/current/mod/core.html#errordocument), e.g.: + +ErrorDocument 404 /404.html + + +# ############################################################################## +# # INTERNET EXPLORER # +# ############################################################################## + +# ------------------------------------------------------------------------------ +# | Better website experience | +# ------------------------------------------------------------------------------ + +# Force IE to render pages in the highest available mode in the various +# cases when it may not: http://hsivonen.iki.fi/doctype/ie-mode.pdf. + + + Header set X-UA-Compatible "IE=edge" + # `mod_headers` can't match based on the content-type, however, we only + # want to send this header for HTML pages and not for the other resources + + Header unset X-UA-Compatible + + + +# ------------------------------------------------------------------------------ +# | Cookie setting from iframes | +# ------------------------------------------------------------------------------ + +# Allow cookies to be set from iframes in IE. + +# +# Header set P3P "policyref=\"/w3c/p3p.xml\", CP=\"IDC DSP COR ADM DEVi TAIi PSA PSD IVAi IVDi CONi HIS OUR IND CNT\"" +# + +# ------------------------------------------------------------------------------ +# | Screen flicker | +# ------------------------------------------------------------------------------ + +# Stop screen flicker in IE on CSS rollovers (this only works in +# combination with the `ExpiresByType` directives for images from below). + +# BrowserMatch "MSIE" brokenvary=1 +# BrowserMatch "Mozilla/4.[0-9]{2}" brokenvary=1 +# BrowserMatch "Opera" !brokenvary +# SetEnvIf brokenvary 1 force-no-vary + + +# ############################################################################## +# # MIME TYPES AND ENCODING # +# ############################################################################## + +# ------------------------------------------------------------------------------ +# | Proper MIME types for all files | +# ------------------------------------------------------------------------------ + + + + # Audio + AddType audio/mp4 m4a f4a f4b + AddType audio/ogg oga ogg + + # JavaScript + # Normalize to standard type (it's sniffed in IE anyways): + # http://tools.ietf.org/html/rfc4329#section-7.2 + AddType application/javascript js jsonp + AddType application/json json + + # Video + AddType video/mp4 mp4 m4v f4v f4p + AddType video/ogg ogv + AddType video/webm webm + AddType video/x-flv flv + + # Web fonts + AddType application/font-woff woff + AddType application/vnd.ms-fontobject eot + + # Browsers usually ignore the font MIME types and sniff the content, + # however, Chrome shows a warning if other MIME types are used for the + # following fonts. + AddType application/x-font-ttf ttc ttf + AddType font/opentype otf + + # Make SVGZ fonts work on iPad: + # https://twitter.com/FontSquirrel/status/14855840545 + AddType image/svg+xml svg svgz + AddEncoding gzip svgz + + # Other + AddType application/octet-stream safariextz + AddType application/x-chrome-extension crx + AddType application/x-opera-extension oex + AddType application/x-shockwave-flash swf + AddType application/x-web-app-manifest+json webapp + AddType application/x-xpinstall xpi + AddType application/xml atom rdf rss xml + AddType image/webp webp + AddType image/x-icon ico + AddType text/cache-manifest appcache manifest + AddType text/vtt vtt + AddType text/x-component htc + AddType text/x-vcard vcf + + + +# ------------------------------------------------------------------------------ +# | UTF-8 encoding | +# ------------------------------------------------------------------------------ + +# Use UTF-8 encoding for anything served as `text/html` or `text/plain`. +AddDefaultCharset utf-8 + +# Force UTF-8 for certain file formats. + + AddCharset utf-8 .atom .css .js .json .rss .vtt .webapp .xml + + + +# ############################################################################## +# # URL REWRITES # +# ############################################################################## + +# ------------------------------------------------------------------------------ +# | Rewrite engine | +# ------------------------------------------------------------------------------ + +# Turning on the rewrite engine and enabling the `FollowSymLinks` option is +# necessary for the following directives to work. + +# If your web host doesn't allow the `FollowSymlinks` option, you may need to +# comment it out and use `Options +SymLinksIfOwnerMatch` but, be aware of the +# performance impact: http://httpd.apache.org/docs/current/misc/perf-tuning.html#symlinks + +# Also, some cloud hosting services require `RewriteBase` to be set: +# http://www.rackspace.com/knowledge_center/frequently-asked-question/why-is-mod-rewrite-not-working-on-my-site + + + Options +FollowSymlinks + # Options +SymLinksIfOwnerMatch + RewriteEngine On + # RewriteBase / + + +# ------------------------------------------------------------------------------ +# | Suppressing / Forcing the "www." at the beginning of URLs | +# ------------------------------------------------------------------------------ + +# The same content should never be available under two different URLs especially +# not with and without "www." at the beginning. This can cause SEO problems +# (duplicate content), therefore, you should choose one of the alternatives and +# redirect the other one. + +# By default option 1 (no "www.") is activated: +# http://no-www.org/faq.php?q=class_b + +# If you'd prefer to use option 2, just comment out all the lines from option 1 +# and uncomment the ones from option 2. + +# IMPORTANT: NEVER USE BOTH RULES AT THE SAME TIME! + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +# Option 1: rewrite www.example.com → example.com + + + RewriteCond %{HTTPS} !=on + RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC] + RewriteRule ^ http://%1%{REQUEST_URI} [R=301,L] + + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +# Option 2: rewrite example.com → www.example.com + +# Be aware that the following might not be a good idea if you use "real" +# subdomains for certain parts of your website. + +# +# RewriteCond %{HTTPS} !=on +# RewriteCond %{HTTP_HOST} !^www\..+$ [NC] +# RewriteRule ^ http://www.%{HTTP_HOST}%{REQUEST_URI} [R=301,L] +# + + +# ############################################################################## +# # SECURITY # +# ############################################################################## + +# ------------------------------------------------------------------------------ +# | Content Security Policy (CSP) | +# ------------------------------------------------------------------------------ + +# You can mitigate the risk of cross-site scripting and other content-injection +# attacks by setting a Content Security Policy which whitelists trusted sources +# of content for your site. + +# The example header below allows ONLY scripts that are loaded from the current +# site's origin (no inline scripts, no CDN, etc). This almost certainly won't +# work as-is for your site! + +# To get all the details you'll need to craft a reasonable policy for your site, +# read: http://html5rocks.com/en/tutorials/security/content-security-policy (or +# see the specification: http://w3.org/TR/CSP). + +# +# Header set Content-Security-Policy "script-src 'self'; object-src 'self'" +# +# Header unset Content-Security-Policy +# +# + +# ------------------------------------------------------------------------------ +# | File access | +# ------------------------------------------------------------------------------ + +# Block access to directories without a default document. +# Usually you should leave this uncommented because you shouldn't allow anyone +# to surf through every directory on your server (which may includes rather +# private places like the CMS's directories). + + + Options -Indexes + + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +# Block access to hidden files and directories. +# This includes directories used by version control systems such as Git and SVN. + + + RewriteCond %{SCRIPT_FILENAME} -d [OR] + RewriteCond %{SCRIPT_FILENAME} -f + RewriteRule "(^|/)\." - [F] + + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +# Block access to backup and source files. +# These files may be left by some text editors and can pose a great security +# danger when anyone has access to them. + + + Order allow,deny + Deny from all + Satisfy All + + +# ------------------------------------------------------------------------------ +# | Secure Sockets Layer (SSL) | +# ------------------------------------------------------------------------------ + +# Rewrite secure requests properly to prevent SSL certificate warnings, e.g.: +# prevent `https://www.example.com` when your certificate only allows +# `https://secure.example.com`. + +# +# RewriteCond %{SERVER_PORT} !^443 +# RewriteRule ^ https://example-domain-please-change-me.com%{REQUEST_URI} [R=301,L] +# + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +# Force client-side SSL redirection. + +# If a user types "example.com" in his browser, the above rule will redirect him +# to the secure version of the site. That still leaves a window of opportunity +# (the initial HTTP connection) for an attacker to downgrade or redirect the +# request. The following header ensures that browser will ONLY connect to your +# server via HTTPS, regardless of what the users type in the address bar. +# http://www.html5rocks.com/en/tutorials/security/transport-layer-security/ + +# +# Header set Strict-Transport-Security max-age=16070400; +# + +# ------------------------------------------------------------------------------ +# | Server software information | +# ------------------------------------------------------------------------------ + +# Avoid displaying the exact Apache version number, the description of the +# generic OS-type and the information about Apache's compiled-in modules. + +# ADD THIS DIRECTIVE IN THE `httpd.conf` AS IT WILL NOT WORK IN THE `.htaccess`! + +# ServerTokens Prod + + +# ############################################################################## +# # WEB PERFORMANCE # +# ############################################################################## + +# ------------------------------------------------------------------------------ +# | Compression | +# ------------------------------------------------------------------------------ + + + + # Force compression for mangled headers. + # http://developer.yahoo.com/blogs/ydn/posts/2010/12/pushing-beyond-gzipping + + + SetEnvIfNoCase ^(Accept-EncodXng|X-cept-Encoding|X{15}|~{15}|-{15})$ ^((gzip|deflate)\s*,?\s*)+|[X~-]{4,13}$ HAVE_Accept-Encoding + RequestHeader append Accept-Encoding "gzip,deflate" env=HAVE_Accept-Encoding + + + + # Compress all output labeled with one of the following MIME-types + # (for Apache versions below 2.3.7, you don't need to enable `mod_filter` + # and can remove the `` and `` lines + # as `AddOutputFilterByType` is still in the core directives). + + AddOutputFilterByType DEFLATE application/atom+xml \ + application/javascript \ + application/json \ + application/rss+xml \ + application/vnd.ms-fontobject \ + application/x-font-ttf \ + application/x-web-app-manifest+json \ + application/xhtml+xml \ + application/xml \ + font/opentype \ + image/svg+xml \ + image/x-icon \ + text/css \ + text/html \ + text/plain \ + text/x-component \ + text/xml + + + + +# ------------------------------------------------------------------------------ +# | Content transformations | +# ------------------------------------------------------------------------------ + +# Prevent some of the mobile network providers from modifying the content of +# your site: http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9.5. + +# +# Header set Cache-Control "no-transform" +# + +# ------------------------------------------------------------------------------ +# | ETag removal | +# ------------------------------------------------------------------------------ + +# Since we're sending far-future expires headers (see below), ETags can +# be removed: http://developer.yahoo.com/performance/rules.html#etags. + +# `FileETag None` is not enough for every server. + + Header unset ETag + + +FileETag None + +# ------------------------------------------------------------------------------ +# | Expires headers (for better cache control) | +# ------------------------------------------------------------------------------ + +# The following expires headers are set pretty far in the future. If you don't +# control versioning with filename-based cache busting, consider lowering the +# cache time for resources like CSS and JS to something like 1 week. + + + + ExpiresActive on + ExpiresDefault "access plus 1 month" + + # CSS + ExpiresByType text/css "access plus 1 year" + + # Data interchange + ExpiresByType application/json "access plus 0 seconds" + ExpiresByType application/xml "access plus 0 seconds" + ExpiresByType text/xml "access plus 0 seconds" + + # Favicon (cannot be renamed!) + ExpiresByType image/x-icon "access plus 1 week" + + # HTML components (HTCs) + ExpiresByType text/x-component "access plus 1 month" + + # HTML + ExpiresByType text/html "access plus 0 seconds" + + # JavaScript + ExpiresByType application/javascript "access plus 1 year" + + # Manifest files + ExpiresByType application/x-web-app-manifest+json "access plus 0 seconds" + ExpiresByType text/cache-manifest "access plus 0 seconds" + + # Media + ExpiresByType audio/ogg "access plus 1 month" + ExpiresByType image/gif "access plus 1 month" + ExpiresByType image/jpeg "access plus 1 month" + ExpiresByType image/png "access plus 1 month" + ExpiresByType video/mp4 "access plus 1 month" + ExpiresByType video/ogg "access plus 1 month" + ExpiresByType video/webm "access plus 1 month" + + # Web feeds + ExpiresByType application/atom+xml "access plus 1 hour" + ExpiresByType application/rss+xml "access plus 1 hour" + + # Web fonts + ExpiresByType application/font-woff "access plus 1 month" + ExpiresByType application/vnd.ms-fontobject "access plus 1 month" + ExpiresByType application/x-font-ttf "access plus 1 month" + ExpiresByType font/opentype "access plus 1 month" + ExpiresByType image/svg+xml "access plus 1 month" + + + +# ------------------------------------------------------------------------------ +# | Filename-based cache busting | +# ------------------------------------------------------------------------------ + +# If you're not using a build process to manage your filename version revving, +# you might want to consider enabling the following directives to route all +# requests such as `/css/style.12345.css` to `/css/style.css`. + +# To understand why this is important and a better idea than `*.css?v231`, read: +# http://stevesouders.com/blog/2008/08/23/revving-filenames-dont-use-querystring + +# +# RewriteCond %{REQUEST_FILENAME} !-f +# RewriteCond %{REQUEST_FILENAME} !-d +# RewriteRule ^(.+)\.(\d+)\.(js|css|png|jpg|gif)$ $1.$3 [L] +# + +# ------------------------------------------------------------------------------ +# | File concatenation | +# ------------------------------------------------------------------------------ + +# Allow concatenation from within specific CSS and JS files, e.g.: +# Inside of `script.combined.js` you could have +# +# +# and they would be included into this single file. + +# +# +# Options +Includes +# AddOutputFilterByType INCLUDES application/javascript application/json +# SetOutputFilter INCLUDES +# +# +# Options +Includes +# AddOutputFilterByType INCLUDES text/css +# SetOutputFilter INCLUDES +# +# + +# ------------------------------------------------------------------------------ +# | Persistent connections | +# ------------------------------------------------------------------------------ + +# Allow multiple requests to be sent over the same TCP connection: +# http://httpd.apache.org/docs/current/en/mod/core.html#keepalive. + +# Enable if you serve a lot of static content but, be aware of the +# possible disadvantages! + +# +# Header set Connection Keep-Alive +# diff --git a/client/.jshintrc b/client/.jshintrc new file mode 100644 index 0000000..52c6a6d --- /dev/null +++ b/client/.jshintrc @@ -0,0 +1,38 @@ +{ + "node": true, + "browser": true, + "esnext": true, + "bitwise": true, + "camelcase": true, + "curly": true, + "eqeqeq": true, + "immed": true, + "indent": 2, + "latedef": true, + "newcap": true, + "noarg": true, + "quotmark": "single", + "regexp": true, + "undef": true, + "unused": true, + "strict": true, + "trailing": true, + "smarttabs": true, + "globals": { + "jQuery": true, + "angular": true, + "console": true, + "$": true, + "_": true, + "moment": true, + "describe": true, + "beforeEach": true, + "module": true, + "inject": true, + "it": true, + "expect": true, + "browser": true, + "element": true, + "by": true + } +} diff --git a/client/app/account/account.js b/client/app/account/account.js new file mode 100644 index 0000000..c276527 --- /dev/null +++ b/client/app/account/account.js @@ -0,0 +1,19 @@ +'use strict'; + +angular.module('markdownFormatWdiffApp') + .config(function ($routeProvider) { + $routeProvider + .when('/login', { + templateUrl: 'app/account/login/login.html', + controller: 'LoginCtrl' + }) + .when('/signup', { + templateUrl: 'app/account/signup/signup.html', + controller: 'SignupCtrl' + }) + .when('/settings', { + templateUrl: 'app/account/settings/settings.html', + controller: 'SettingsCtrl', + authenticate: true + }); + }); \ No newline at end of file diff --git a/client/app/account/login/login.controller.js b/client/app/account/login/login.controller.js new file mode 100644 index 0000000..b96628a --- /dev/null +++ b/client/app/account/login/login.controller.js @@ -0,0 +1,29 @@ +'use strict'; + +angular.module('markdownFormatWdiffApp') + .controller('LoginCtrl', function ($scope, Auth, $location, $window) { + $scope.user = {}; + $scope.errors = {}; + + $scope.login = function(form) { + $scope.submitted = true; + + if(form.$valid) { + Auth.login({ + email: $scope.user.email, + password: $scope.user.password + }) + .then( function() { + // Logged in, redirect to home + $location.path('/'); + }) + .catch( function(err) { + $scope.errors.other = err.message; + }); + } + }; + + $scope.loginOauth = function(provider) { + $window.location.href = '/auth/' + provider; + }; + }); diff --git a/client/app/account/login/login.jade b/client/app/account/login/login.jade new file mode 100644 index 0000000..4cfa811 --- /dev/null +++ b/client/app/account/login/login.jade @@ -0,0 +1,42 @@ +div(ng-include='"components/navbar/navbar.html"') +.container + .row + .col-sm-12 + h1 Login + + .col-sm-12 + form.form(name='form', ng-submit='login(form)', novalidate='') + .form-group + label Email + input.form-control(type='text', name='email', ng-model='user.email') + .form-group + label Password + input.form-control(type='password', name='password', ng-model='user.password') + + .form-group.has-error + p.help-block(ng-show='form.email.$error.required && form.password.$error.required && submitted') + | Please enter your email and password. + p.help-block {{ errors.other }} + + div + button.btn.btn-inverse.btn-lg.btn-login(type='submit') + | Login + = ' ' + a.btn.btn-default.btn-lg.btn-register(href='/signup') + | Register + + hr + + div + a.btn.btn-facebook(href='', ng-click='loginOauth("facebook")') + i.fa.fa-facebook + | Connect with Facebook + = ' ' + a.btn.btn-google-plus(href='', ng-click='loginOauth("google")') + i.fa.fa-google-plus + | Connect with Google+ + = ' ' + a.btn.btn-twitter(href='', ng-click='loginOauth("twitter")') + i.fa.fa-twitter + | Connect with Twitter + hr diff --git a/client/app/account/login/login.scss b/client/app/account/login/login.scss new file mode 100644 index 0000000..d3eafac --- /dev/null +++ b/client/app/account/login/login.scss @@ -0,0 +1,30 @@ +// Colors +// -------------------------------------------------- + +$btnText: #fff; +$btnTextAlt: #000; + +$btnFacebookBackground: #3B5998; +$btnFacebookBackgroundHighlight: #133783; +$btnTwitterBackground: #2daddc; +$btnTwitterBackgroundHighlight: #0271bf; +$btnGooglePlusBackground: #dd4b39; +$btnGooglePlusBackgroundHighlight: #c53727; +$btnGithubBackground: #fafafa; +$btnGithubBackgroundHighlight: #ccc; + +// Social buttons +// -------------------------------------------------- + +.btn-facebook { + @include button-variant($btnText, $btnFacebookBackgroundHighlight, $btnFacebookBackgroundHighlight); +} +.btn-twitter { + @include button-variant($btnText, $btnTwitterBackground, $btnTwitterBackgroundHighlight); +} +.btn-google-plus { + @include button-variant($btnText, $btnGooglePlusBackground, $btnGooglePlusBackgroundHighlight); +} +.btn-github { + @include button-variant($btnTextAlt, $btnGithubBackground, $btnGithubBackgroundHighlight); +} diff --git a/client/app/account/settings/settings.controller.js b/client/app/account/settings/settings.controller.js new file mode 100644 index 0000000..32eabfa --- /dev/null +++ b/client/app/account/settings/settings.controller.js @@ -0,0 +1,21 @@ +'use strict'; + +angular.module('markdownFormatWdiffApp') + .controller('SettingsCtrl', function ($scope, User, Auth) { + $scope.errors = {}; + + $scope.changePassword = function(form) { + $scope.submitted = true; + if(form.$valid) { + Auth.changePassword( $scope.user.oldPassword, $scope.user.newPassword ) + .then( function() { + $scope.message = 'Password successfully changed.'; + }) + .catch( function() { + form.password.$setValidity('mongoose', false); + $scope.errors.other = 'Incorrect password'; + $scope.message = ''; + }); + } + }; + }); diff --git a/client/app/account/settings/settings.jade b/client/app/account/settings/settings.jade new file mode 100644 index 0000000..2dc55d4 --- /dev/null +++ b/client/app/account/settings/settings.jade @@ -0,0 +1,21 @@ +div(ng-include='"components/navbar/navbar.html"') +.container + .row + .col-sm-12 + h1 Change Password + .col-sm-12 + form.form(name='form', ng-submit='changePassword(form)', novalidate='') + .form-group + label Current Password + input.form-control(type='password', name='password', ng-model='user.oldPassword', mongoose-error='') + p.help-block(ng-show='form.password.$error.mongoose') + | {{ errors.other }} + .form-group + label New Password + input.form-control(type='password', name='newPassword', ng-model='user.newPassword', ng-minlength='3', required='') + p.help-block(ng-show='(form.newPassword.$error.minlength || form.newPassword.$error.required) && (form.newPassword.$dirty || submitted)') + | Password must be at least 3 characters. + + p.help-block {{ message }} + + button.btn.btn-lg.btn-primary(type='submit') Save changes diff --git a/client/app/account/signup/signup.controller.js b/client/app/account/signup/signup.controller.js new file mode 100644 index 0000000..734cb9c --- /dev/null +++ b/client/app/account/signup/signup.controller.js @@ -0,0 +1,37 @@ +'use strict'; + +angular.module('markdownFormatWdiffApp') + .controller('SignupCtrl', function ($scope, Auth, $location, $window) { + $scope.user = {}; + $scope.errors = {}; + + $scope.register = function(form) { + $scope.submitted = true; + + if(form.$valid) { + Auth.createUser({ + name: $scope.user.name, + email: $scope.user.email, + password: $scope.user.password + }) + .then( function() { + // Account created, redirect to home + $location.path('/'); + }) + .catch( function(err) { + err = err.data; + $scope.errors = {}; + + // Update validity of form fields that match the mongoose errors + angular.forEach(err.errors, function(error, field) { + form[field].$setValidity('mongoose', false); + $scope.errors[field] = error.message; + }); + }); + } + }; + + $scope.loginOauth = function(provider) { + $window.location.href = '/auth/' + provider; + }; + }); diff --git a/client/app/account/signup/signup.jade b/client/app/account/signup/signup.jade new file mode 100644 index 0000000..9677155 --- /dev/null +++ b/client/app/account/signup/signup.jade @@ -0,0 +1,57 @@ +div(ng-include='"components/navbar/navbar.html"') +.container + .row + .col-sm-12 + h1 Sign up + .col-sm-12 + form.form(name='form', ng-submit='register(form)', novalidate='') + .form-group(ng-class='{ "has-success": form.name.$valid && submitted,\ + "has-error": form.name.$invalid && submitted }') + label Name + input.form-control(type='text', name='name', ng-model='user.name', required='') + p.help-block(ng-show='form.name.$error.required && submitted') + | A name is required + + .form-group(ng-class='{ "has-success": form.email.$valid && submitted,\ + "has-error": form.email.$invalid && submitted }') + label Email + input.form-control(type='email', name='email', ng-model='user.email', required='', mongoose-error='') + p.help-block(ng-show='form.email.$error.email && submitted') + | Doesn't look like a valid email. + p.help-block(ng-show='form.email.$error.required && submitted') + | What's your email address? + p.help-block(ng-show='form.email.$error.mongoose') + | {{ errors.email }} + + .form-group(ng-class='{ "has-success": form.password.$valid && submitted,\ + "has-error": form.password.$invalid && submitted }') + label Password + input.form-control(type='password', name='password', ng-model='user.password', ng-minlength='3', required='', mongoose-error='') + p.help-block(ng-show='(form.password.$error.minlength || form.password.$error.required) && submitted') + | Password must be at least 3 characters. + p.help-block(ng-show='form.password.$error.mongoose') + | {{ errors.password }} + + div + button.btn.btn-inverse.btn-lg.btn-login(type='submit') + | Sign up + = ' ' + a.btn.btn-default.btn-lg.btn-register(href='/login') + | Login + + + hr + + div + a.btn.btn-facebook(href='', ng-click='loginOauth("facebook")') + i.fa.fa-facebook + | Connect with Facebook + = ' ' + a.btn.btn-google-plus(href='', ng-click='loginOauth("google")') + i.fa.fa-google-plus + | Connect with Google+ + = ' ' + a.btn.btn-twitter(href='', ng-click='loginOauth("twitter")') + i.fa.fa-twitter + | Connect with Twitter + hr diff --git a/client/app/admin/admin.controller.js b/client/app/admin/admin.controller.js new file mode 100644 index 0000000..fe47a51 --- /dev/null +++ b/client/app/admin/admin.controller.js @@ -0,0 +1,17 @@ +'use strict'; + +angular.module('markdownFormatWdiffApp') + .controller('AdminCtrl', function ($scope, $http, Auth, User) { + + // Use the User $resource to fetch all users + $scope.users = User.query(); + + $scope.delete = function(user) { + User.remove({ id: user._id }); + angular.forEach($scope.users, function(u, i) { + if (u === user) { + $scope.users.splice(i, 1); + } + }); + }; + }); diff --git a/client/app/admin/admin.jade b/client/app/admin/admin.jade new file mode 100644 index 0000000..fd80a0b --- /dev/null +++ b/client/app/admin/admin.jade @@ -0,0 +1,11 @@ +div(ng-include='"components/navbar/navbar.html"') +.container + p + | The delete user and user index api routes are restricted to users with the 'admin' role. + ul.list-group + li.list-group-item(ng-repeat='user in users') + strong {{user.name}} + br + span.text-muted {{user.email}} + a.trash(ng-click='delete(user)') + span.glyphicon.glyphicon-trash.pull-right \ No newline at end of file diff --git a/client/app/admin/admin.js b/client/app/admin/admin.js new file mode 100644 index 0000000..093d7df --- /dev/null +++ b/client/app/admin/admin.js @@ -0,0 +1,10 @@ +'use strict'; + +angular.module('markdownFormatWdiffApp') + .config(function ($routeProvider) { + $routeProvider + .when('/admin', { + templateUrl: 'app/admin/admin.html', + controller: 'AdminCtrl' + }); + }); \ No newline at end of file diff --git a/client/app/admin/admin.scss b/client/app/admin/admin.scss new file mode 100644 index 0000000..a6f536d --- /dev/null +++ b/client/app/admin/admin.scss @@ -0,0 +1 @@ +.trash { color:rgb(209, 91, 71); } diff --git a/client/app/app.js b/client/app/app.js new file mode 100644 index 0000000..5d7808f --- /dev/null +++ b/client/app/app.js @@ -0,0 +1,56 @@ +'use strict'; + +angular.module('markdownFormatWdiffApp', [ + 'ngCookies', + 'ngResource', + 'ngSanitize', + 'ngRoute', + 'ui.bootstrap', + 'btford.markdown' +]) + .config(function ($routeProvider, $locationProvider, $httpProvider) { + $routeProvider + .otherwise({ + redirectTo: '/' + }); + + $locationProvider.html5Mode(true); + $httpProvider.interceptors.push('authInterceptor'); + }) + + .factory('authInterceptor', function ($rootScope, $q, $cookieStore, $location) { + return { + // Add authorization token to headers + request: function (config) { + config.headers = config.headers || {}; + if ($cookieStore.get('token')) { + config.headers.Authorization = 'Bearer ' + $cookieStore.get('token'); + } + return config; + }, + + // Intercept 401s and redirect you to login + responseError: function(response) { + if(response.status === 401) { + $location.path('/login'); + // remove any stale tokens + $cookieStore.remove('token'); + return $q.reject(response); + } + else { + return $q.reject(response); + } + } + }; + }) + + .run(function ($rootScope, $location, Auth) { + // Redirect to login if route requires auth and you're not logged in + $rootScope.$on('$routeChangeStart', function (event, next) { + Auth.isLoggedInAsync(function(loggedIn) { + if (next.authenticate && !loggedIn) { + $location.path('/login'); + } + }); + }); + }); \ No newline at end of file diff --git a/client/app/app.scss b/client/app/app.scss new file mode 100644 index 0000000..140d478 --- /dev/null +++ b/client/app/app.scss @@ -0,0 +1,24 @@ +$icon-font-path: "/bower_components/bootstrap-sass-official/vendor/assets/fonts/bootstrap/"; +$fa-font-path: "/bower_components/font-awesome/fonts"; + +@import 'bootstrap-sass-official/vendor/assets/stylesheets/bootstrap'; +@import 'font-awesome/scss/font-awesome'; + +/** + * App-wide Styles + */ + +.browsehappy { + margin: 0.2em 0; + background: #ccc; + color: #000; + padding: 0.2em 0; +} + +// Component styles are injected through grunt +// injector +@import 'account/login/login.scss'; +@import 'admin/admin.scss'; +@import 'wdiff/wdiff.scss'; +@import 'modal/modal.scss'; +// endinjector \ No newline at end of file diff --git a/client/app/wdiff/wdiff.controller.js b/client/app/wdiff/wdiff.controller.js new file mode 100644 index 0000000..0de17b2 --- /dev/null +++ b/client/app/wdiff/wdiff.controller.js @@ -0,0 +1,49 @@ +'use strict'; + +angular.module('markdownFormatWdiffApp') + .controller('WdiffCtrl', function ($scope, $http) { + $scope.docA = ""; + $scope.docB = ""; + $scope.wdiff = ""; + $scope.wdiffMarkdown = ""; + $scope.displayAsMarkdown = true; + + $scope.compare = function() { + $http.post('/api/wdiff', //+($scope.displayAsMarkdown ? '/markdown': ''), + { a: $scope.docA, b: $scope.docB }, + {headers:{"Content-Type":"application/json"}}) + .success(function (data) { + if ($scope.displayAsMarkdown) { + var markdown = data.wdiff; + markdown = markdown.replace(/\[-/g, ''); + markdown = markdown.replace(/-\]/g, ''); + markdown = markdown.replace(/{\+/g, ''); + markdown = markdown.replace(/\+}/g, ''); + + $scope.wdiffMarkdown = markdown; //data.markdown; + $scope.wdiff = ''; + } + else { + + $scope.wdiff = data.wdiff; + $scope.wdiffMarkdown = ''; + } + }); + }; + +/* courtesy some rando (doesn't work): + function expandTextarea(id) { + var element = document.getElementById(id); + + element.addEventListener('keyup', function() { + this.style.overflow = 'hidden'; + this.style.height = 0; + this.style.height = this.scrollHeight + 'px'; + }, false); + } + + expandTextarea('docA'); + expandTextarea('docB'); + */ + + }) diff --git a/client/app/wdiff/wdiff.jade b/client/app/wdiff/wdiff.jade new file mode 100644 index 0000000..ee5f052 --- /dev/null +++ b/client/app/wdiff/wdiff.jade @@ -0,0 +1,52 @@ +div(ng-include='"components/navbar/navbar.html"') + +header#banner.hero-unit + .container + h1 wdiff + +.container + .row + .col-lg-3.col-sm-12 + .col-lg-6.col-sm-12 + div.diff-container + div(ng-bind='wdiff') + .col-lg-3.col-sm-12 + + .row + .col-lg-3.col-sm-12 + .col-lg-6.col-sm-12 + div.diff-container + div(btf-markdown='wdiffMarkdown') + .col-lg-3.col-sm-12 + + hr + + form.row + .col-lg-12.form-group + div.checkbox + label + input(type='checkbox', ng-model='displayAsMarkdown') + | Display as Markdown + + .col-lg-12.form-group + div.btn-group.btn-group-justified(role='group') + a.btn.btn-primary(type='button', ng-click='compare()') compare + + .col-lg-6.col-sm-12.form-group + label(for='docA') + | Document A + textarea.form-control(id='docA', ng-model='docA') + + .col-lg-6.col-sm-12.form-group + label(for='docB') + | Document B + textarea.form-control(id='docB', ng-model='docB') + + +footer.footer + .container + p + | Wdiff online + = ' | ' + a(href='https://madanworb.com') Adam Brown + \ No newline at end of file diff --git a/client/app/wdiff/wdiff.js b/client/app/wdiff/wdiff.js new file mode 100644 index 0000000..f9899a3 --- /dev/null +++ b/client/app/wdiff/wdiff.js @@ -0,0 +1,10 @@ +'use strict'; + +angular.module('markdownFormatWdiffApp') + .config(function ($routeProvider) { + $routeProvider + .when('/', { + templateUrl: 'app/wdiff/wdiff.html', + controller: 'WdiffCtrl' + }); + }); \ No newline at end of file diff --git a/client/app/wdiff/wdiff.scss b/client/app/wdiff/wdiff.scss new file mode 100644 index 0000000..82a6699 --- /dev/null +++ b/client/app/wdiff/wdiff.scss @@ -0,0 +1,37 @@ +.wdiff-container .ins { +} + +.wdiff-container .del { +} + +#docA, #docB { + resize: vertical; + min-height: 600px; +} + +#banner { + border-bottom: none; + margin-top: -20px; +} + +#banner h1 { + font-size: 60px; + line-height: 1; + letter-spacing: -1px; +} + +.hero-unit { + position: relative; + padding: 30px 15px; + color: #F5F5F5; + text-align: center; + text-shadow: 0 1px 0 rgba(0, 0, 0, 0.1); + background: #4393B9; +} + +.footer { + text-align: center; + padding: 30px 0; + margin-top: 70px; + border-top: 1px solid #E5E5E5; +} \ No newline at end of file diff --git a/client/assets/images/yeoman.png b/client/assets/images/yeoman.png new file mode 100644 index 0000000..7d0a1ac Binary files /dev/null and b/client/assets/images/yeoman.png differ diff --git a/client/components/auth/auth.service.js b/client/components/auth/auth.service.js new file mode 100644 index 0000000..0d1a79c --- /dev/null +++ b/client/components/auth/auth.service.js @@ -0,0 +1,146 @@ +'use strict'; + +angular.module('markdownFormatWdiffApp') + .factory('Auth', function Auth($location, $rootScope, $http, User, $cookieStore, $q) { + var currentUser = {}; + if($cookieStore.get('token')) { + currentUser = User.get(); + } + + return { + + /** + * Authenticate user and save token + * + * @param {Object} user - login info + * @param {Function} callback - optional + * @return {Promise} + */ + login: function(user, callback) { + var cb = callback || angular.noop; + var deferred = $q.defer(); + + $http.post('/auth/local', { + email: user.email, + password: user.password + }). + success(function(data) { + $cookieStore.put('token', data.token); + currentUser = User.get(); + deferred.resolve(data); + return cb(); + }). + error(function(err) { + this.logout(); + deferred.reject(err); + return cb(err); + }.bind(this)); + + return deferred.promise; + }, + + /** + * Delete access token and user info + * + * @param {Function} + */ + logout: function() { + $cookieStore.remove('token'); + currentUser = {}; + }, + + /** + * Create a new user + * + * @param {Object} user - user info + * @param {Function} callback - optional + * @return {Promise} + */ + createUser: function(user, callback) { + var cb = callback || angular.noop; + + return User.save(user, + function(data) { + $cookieStore.put('token', data.token); + currentUser = User.get(); + return cb(user); + }, + function(err) { + this.logout(); + return cb(err); + }.bind(this)).$promise; + }, + + /** + * Change password + * + * @param {String} oldPassword + * @param {String} newPassword + * @param {Function} callback - optional + * @return {Promise} + */ + changePassword: function(oldPassword, newPassword, callback) { + var cb = callback || angular.noop; + + return User.changePassword({ id: currentUser._id }, { + oldPassword: oldPassword, + newPassword: newPassword + }, function(user) { + return cb(user); + }, function(err) { + return cb(err); + }).$promise; + }, + + /** + * Gets all available info on authenticated user + * + * @return {Object} user + */ + getCurrentUser: function() { + return currentUser; + }, + + /** + * Check if a user is logged in + * + * @return {Boolean} + */ + isLoggedIn: function() { + return currentUser.hasOwnProperty('role'); + }, + + /** + * Waits for currentUser to resolve before checking if user is logged in + */ + isLoggedInAsync: function(cb) { + if(currentUser.hasOwnProperty('$promise')) { + currentUser.$promise.then(function() { + cb(true); + }).catch(function() { + cb(false); + }); + } else if(currentUser.hasOwnProperty('role')) { + cb(true); + } else { + cb(false); + } + }, + + /** + * Check if a user is an admin + * + * @return {Boolean} + */ + isAdmin: function() { + return currentUser.role === 'admin'; + }, + + /** + * Get auth token + */ + getToken: function() { + return $cookieStore.get('token'); + } + }; + }); diff --git a/client/components/auth/user.service.js b/client/components/auth/user.service.js new file mode 100644 index 0000000..5641abe --- /dev/null +++ b/client/components/auth/user.service.js @@ -0,0 +1,22 @@ +'use strict'; + +angular.module('markdownFormatWdiffApp') + .factory('User', function ($resource) { + return $resource('/api/users/:id/:controller', { + id: '@_id' + }, + { + changePassword: { + method: 'PUT', + params: { + controller:'password' + } + }, + get: { + method: 'GET', + params: { + id:'me' + } + } + }); + }); diff --git a/client/components/modal/modal.jade b/client/components/modal/modal.jade new file mode 100644 index 0000000..71b4321 --- /dev/null +++ b/client/components/modal/modal.jade @@ -0,0 +1,8 @@ +.modal-header + button.close(ng-if='modal.dismissable', type='button', ng-click='$dismiss()') × + h4.modal-title(ng-if='modal.title', ng-bind='modal.title') +.modal-body + p(ng-if='modal.text', ng-bind='modal.text') + div(ng-if='modal.html', ng-bind-html='modal.html') +.modal-footer + button.btn(ng-repeat='button in modal.buttons', ng-class='button.classes', ng-click='button.click($event)', ng-bind='button.text') diff --git a/client/components/modal/modal.scss b/client/components/modal/modal.scss new file mode 100644 index 0000000..3b0b9d9 --- /dev/null +++ b/client/components/modal/modal.scss @@ -0,0 +1,25 @@ +.modal-primary, +.modal-info, +.modal-success, +.modal-warning, +.modal-danger { + .modal-header { + color: #fff; + border-radius: 5px 5px 0 0; + } +} +.modal-primary .modal-header { + background: $brand-primary; +} +.modal-info .modal-header { + background: $brand-info; +} +.modal-success .modal-header { + background: $brand-success; +} +.modal-warning .modal-header { + background: $brand-warning; +} +.modal-danger .modal-header { + background: $brand-danger; +} diff --git a/client/components/modal/modal.service.js b/client/components/modal/modal.service.js new file mode 100644 index 0000000..6266c7a --- /dev/null +++ b/client/components/modal/modal.service.js @@ -0,0 +1,77 @@ +'use strict'; + +angular.module('markdownFormatWdiffApp') + .factory('Modal', function ($rootScope, $modal) { + /** + * Opens a modal + * @param {Object} scope - an object to be merged with modal's scope + * @param {String} modalClass - (optional) class(es) to be applied to the modal + * @return {Object} - the instance $modal.open() returns + */ + function openModal(scope, modalClass) { + var modalScope = $rootScope.$new(); + scope = scope || {}; + modalClass = modalClass || 'modal-default'; + + angular.extend(modalScope, scope); + + return $modal.open({ + templateUrl: 'components/modal/modal.html', + windowClass: modalClass, + scope: modalScope + }); + } + + // Public API here + return { + + /* Confirmation modals */ + confirm: { + + /** + * Create a function to open a delete confirmation modal (ex. ng-click='myModalFn(name, arg1, arg2...)') + * @param {Function} del - callback, ran when delete is confirmed + * @return {Function} - the function to open the modal (ex. myModalFn) + */ + delete: function(del) { + del = del || angular.noop; + + /** + * Open a delete confirmation modal + * @param {String} name - name or info to show on modal + * @param {All} - any additional args are passed staight to del callback + */ + return function() { + var args = Array.prototype.slice.call(arguments), + name = args.shift(), + deleteModal; + + deleteModal = openModal({ + modal: { + dismissable: true, + title: 'Confirm Delete', + html: '

Are you sure you want to delete ' + name + ' ?

', + buttons: [{ + classes: 'btn-danger', + text: 'Delete', + click: function(e) { + deleteModal.close(e); + } + }, { + classes: 'btn-default', + text: 'Cancel', + click: function(e) { + deleteModal.dismiss(e); + } + }] + } + }, 'modal-danger'); + + deleteModal.result.then(function(event) { + del.apply(event, args); + }); + }; + } + } + }; + }); diff --git a/client/components/mongoose-error/mongoose-error.directive.js b/client/components/mongoose-error/mongoose-error.directive.js new file mode 100644 index 0000000..ee0a911 --- /dev/null +++ b/client/components/mongoose-error/mongoose-error.directive.js @@ -0,0 +1,17 @@ +'use strict'; + +/** + * Removes server error when user updates input + */ +angular.module('markdownFormatWdiffApp') + .directive('mongooseError', function () { + return { + restrict: 'A', + require: 'ngModel', + link: function(scope, element, attrs, ngModel) { + element.on('keydown', function() { + return ngModel.$setValidity('mongoose', true); + }); + } + }; + }); \ No newline at end of file diff --git a/client/components/navbar/navbar.controller.js b/client/components/navbar/navbar.controller.js new file mode 100644 index 0000000..8569095 --- /dev/null +++ b/client/components/navbar/navbar.controller.js @@ -0,0 +1,23 @@ +'use strict'; + +angular.module('markdownFormatWdiffApp') + .controller('NavbarCtrl', function ($scope, $location, Auth) { + $scope.menu = [{ + 'title': 'Home', + 'link': '/' + }]; + + $scope.isCollapsed = true; + $scope.isLoggedIn = Auth.isLoggedIn; + $scope.isAdmin = Auth.isAdmin; + $scope.getCurrentUser = Auth.getCurrentUser; + + $scope.logout = function() { + Auth.logout(); + $location.path('/login'); + }; + + $scope.isActive = function(route) { + return route === $location.path(); + }; + }); \ No newline at end of file diff --git a/client/components/navbar/navbar.jade b/client/components/navbar/navbar.jade new file mode 100644 index 0000000..a7f723f --- /dev/null +++ b/client/components/navbar/navbar.jade @@ -0,0 +1,34 @@ +div.navbar.navbar-default.navbar-static-top(ng-controller='NavbarCtrl') + div.container + div.navbar-header + button.navbar-toggle(type='button', ng-click='isCollapsed = !isCollapsed') + span.sr-only Toggle navigation + span.icon-bar + span.icon-bar + span.icon-bar + a.navbar-brand(href='/') markdown-format-wdiff + + div#navbar-main.navbar-collapse.collapse(collapse='isCollapsed') + ul.nav.navbar-nav + li(ng-repeat='item in menu', ng-class='{active: isActive(item.link)}') + a(ng-href='{{item.link}}') {{item.title}} + + li(ng-show='isAdmin()', ng-class='{active: isActive("/admin")}') + a(href='/admin') Admin + + ul.nav.navbar-nav.navbar-right + li(ng-hide='isLoggedIn()', ng-class='{active: isActive("/signup")}') + a(href='/signup') Sign up + + li(ng-hide='isLoggedIn()', ng-class='{active: isActive("/login")}') + a(href='/login') Login + + li(ng-show='isLoggedIn()') + p.navbar-text Hello {{ getCurrentUser().name }} + + li(ng-show='isLoggedIn()', ng-class='{active: isActive("/settings")}') + a(href='/settings') + span.glyphicon.glyphicon-cog + + li(ng-show='isLoggedIn()', ng-class='{active: isActive("/logout")}') + a(href='', ng-click='logout()') Logout \ No newline at end of file diff --git a/client/favicon.ico b/client/favicon.ico new file mode 100644 index 0000000..8a163fb Binary files /dev/null and b/client/favicon.ico differ diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..308f9a6 --- /dev/null +++ b/client/index.html @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/robots.txt b/client/robots.txt new file mode 100644 index 0000000..9417495 --- /dev/null +++ b/client/robots.txt @@ -0,0 +1,3 @@ +# robotstxt.org + +User-agent: * diff --git a/e2e/main/main.po.js b/e2e/main/main.po.js new file mode 100644 index 0000000..6718608 --- /dev/null +++ b/e2e/main/main.po.js @@ -0,0 +1,15 @@ +/** + * This file uses the Page Object pattern to define the main page for tests + * https://docs.google.com/presentation/d/1B6manhG0zEXkC-H-tPo2vwU06JhL8w9-XCF9oehXzAQ + */ + +'use strict'; + +var MainPage = function() { + this.heroEl = element(by.css('.hero-unit')); + this.h1El = this.heroEl.element(by.css('h1')); + this.imgEl = this.heroEl.element(by.css('img')); +}; + +module.exports = new MainPage(); + diff --git a/e2e/main/main.spec.js b/e2e/main/main.spec.js new file mode 100644 index 0000000..61745a8 --- /dev/null +++ b/e2e/main/main.spec.js @@ -0,0 +1,16 @@ +'use strict'; + +describe('Main View', function() { + var page; + + beforeEach(function() { + browser.get('/'); + page = require('./main.po'); + }); + + it('should include jumbotron with correct data', function() { + expect(page.h1El.getText()).toBe('\'Allo, \'Allo!'); + expect(page.imgEl.getAttribute('src')).toMatch(/assets\/images\/yeoman.png$/); + expect(page.imgEl.getAttribute('alt')).toBe('I\'m Yeoman'); + }); +}); diff --git a/karma.conf.js b/karma.conf.js new file mode 100644 index 0000000..842accf --- /dev/null +++ b/karma.conf.js @@ -0,0 +1,79 @@ +// Karma configuration +// http://karma-runner.github.io/0.10/config/configuration-file.html + +module.exports = function(config) { + config.set({ + // base path, that will be used to resolve files and exclude + basePath: '', + + // testing framework to use (jasmine/mocha/qunit/...) + frameworks: ['jasmine'], + + // list of files / patterns to load in the browser + files: [ + 'client/bower_components/jquery/dist/jquery.js', + 'client/bower_components/angular/angular.js', + 'client/bower_components/angular-mocks/angular-mocks.js', + 'client/bower_components/angular-resource/angular-resource.js', + 'client/bower_components/angular-cookies/angular-cookies.js', + 'client/bower_components/angular-sanitize/angular-sanitize.js', + 'client/bower_components/angular-route/angular-route.js', + 'client/bower_components/angular-bootstrap/ui-bootstrap-tpls.js', + 'client/bower_components/lodash/dist/lodash.compat.js', + 'client/app/app.js', + 'client/app/app.coffee', + 'client/app/**/*.js', + 'client/app/**/*.coffee', + 'client/components/**/*.js', + 'client/components/**/*.coffee', + 'client/app/**/*.jade', + 'client/components/**/*.jade', + 'client/app/**/*.html', + 'client/components/**/*.html' + ], + + preprocessors: { + '**/*.jade': 'ng-jade2js', + '**/*.html': 'html2js', + '**/*.coffee': 'coffee', + }, + + ngHtml2JsPreprocessor: { + stripPrefix: 'client/' + }, + + ngJade2JsPreprocessor: { + stripPrefix: 'client/' + }, + + // list of files / patterns to exclude + exclude: [], + + // web server port + port: 8080, + + // level of logging + // possible values: LOG_DISABLE || LOG_ERROR || LOG_WARN || LOG_INFO || LOG_DEBUG + logLevel: config.LOG_INFO, + + + // enable / disable watching file and executing tests whenever any file changes + autoWatch: false, + + + // Start these browsers, currently available: + // - Chrome + // - ChromeCanary + // - Firefox + // - Opera + // - Safari (only Mac) + // - PhantomJS + // - IE (only Windows) + browsers: ['PhantomJS'], + + + // Continuous Integration mode + // if true, it capture browsers, run tests and exit + singleRun: false + }); +}; diff --git a/npm-debug.log b/npm-debug.log new file mode 100644 index 0000000..702d7b7 --- /dev/null +++ b/npm-debug.log @@ -0,0 +1,98 @@ +0 info it worked if it ends with ok +1 verbose cli [ '/usr/bin/node', +1 verbose cli '/usr/bin/npm', +1 verbose cli 'install', +1 verbose cli '--save', +1 verbose cli 'child_process' ] +2 info using npm@1.4.28 +3 info using node@v0.10.36 +4 verbose node symlink /usr/bin/node +5 warn package.json fs@0.0.2 fs is also the name of a node core module. +6 warn package.json fs@0.0.2 No description +7 warn package.json fs@0.0.2 No repository field. +8 warn package.json fs@0.0.2 No README data +9 warn package.json karma-chrome-launcher@0.1.7 No README data +10 warn package.json karma-coffee-preprocessor@0.2.1 No README data +11 warn package.json karma-firefox-launcher@0.1.4 No README data +12 warn package.json karma-phantomjs-launcher@0.1.4 No README data +13 warn package.json passport-local@0.1.6 No README data +14 verbose readDependencies using package.json deps +15 verbose cache add [ 'child_process', null ] +16 verbose cache add name=undefined spec="child_process" args=["child_process",null] +17 verbose parsed url { protocol: null, +17 verbose parsed url slashes: null, +17 verbose parsed url auth: null, +17 verbose parsed url host: null, +17 verbose parsed url port: null, +17 verbose parsed url hostname: null, +17 verbose parsed url hash: null, +17 verbose parsed url search: null, +17 verbose parsed url query: null, +17 verbose parsed url pathname: 'child_process', +17 verbose parsed url path: 'child_process', +17 verbose parsed url href: 'child_process' } +18 silly lockFile 4845fa97-child-process child_process +19 verbose lock child_process /home/docker/.npm/4845fa97-child-process.lock +20 silly lockFile 4845fa97-child-process child_process +21 silly lockFile 4845fa97-child-process child_process +22 verbose addNamed [ 'child_process', '' ] +23 verbose addNamed [ null, '*' ] +24 silly lockFile 512a7ef6-child-process child_process@ +25 verbose lock child_process@ /home/docker/.npm/512a7ef6-child-process.lock +26 silly addNameRange { name: 'child_process', range: '*', hasData: false } +27 verbose request where is /child_process +28 verbose request registry https://registry.npmjs.org/ +29 verbose request id 64c0f71547b88d8b +30 verbose url raw /child_process +31 verbose url resolving [ 'https://registry.npmjs.org/', './child_process' ] +32 verbose url resolved https://registry.npmjs.org/child_process +33 verbose request where is https://registry.npmjs.org/child_process +34 info trying registry request attempt 1 at 00:16:07 +35 http GET https://registry.npmjs.org/child_process +36 http 404 https://registry.npmjs.org/child_process +37 verbose headers { date: 'Thu, 05 Feb 2015 00:15:58 GMT', +37 verbose headers server: 'CouchDB/1.6.1 (Erlang OTP/R14B04)', +37 verbose headers 'content-type': 'application/json', +37 verbose headers 'cache-control': 'max-age=0', +37 verbose headers 'content-length': '52', +37 verbose headers 'accept-ranges': 'bytes', +37 verbose headers via: '1.1 varnish', +37 verbose headers age: '0', +37 verbose headers 'x-served-by': 'cache-iad2144-IAD', +37 verbose headers 'x-cache': 'MISS', +37 verbose headers 'x-cache-hits': '0', +37 verbose headers 'x-timer': 'S1423095358.512957,VS0,VE62', +37 verbose headers 'keep-alive': 'timeout=10, max=50', +37 verbose headers connection: 'Keep-Alive' } +38 silly registry.get cb [ 404, +38 silly registry.get { date: 'Thu, 05 Feb 2015 00:15:58 GMT', +38 silly registry.get server: 'CouchDB/1.6.1 (Erlang OTP/R14B04)', +38 silly registry.get 'content-type': 'application/json', +38 silly registry.get 'cache-control': 'max-age=0', +38 silly registry.get 'content-length': '52', +38 silly registry.get 'accept-ranges': 'bytes', +38 silly registry.get via: '1.1 varnish', +38 silly registry.get age: '0', +38 silly registry.get 'x-served-by': 'cache-iad2144-IAD', +38 silly registry.get 'x-cache': 'MISS', +38 silly registry.get 'x-cache-hits': '0', +38 silly registry.get 'x-timer': 'S1423095358.512957,VS0,VE62', +38 silly registry.get 'keep-alive': 'timeout=10, max=50', +38 silly registry.get connection: 'Keep-Alive' } ] +39 silly lockFile 512a7ef6-child-process child_process@ +40 silly lockFile 512a7ef6-child-process child_process@ +41 error 404 404 Not Found: child_process +41 error 404 +41 error 404 'child_process' is not in the npm registry. +41 error 404 You should bug the author to publish it +41 error 404 It was specified as a dependency of 'markdown-format-wdiff' +41 error 404 +41 error 404 Note that you can also install from a +41 error 404 tarball, folder, or http url, or git url. +42 error System Linux 3.13.0-32-generic +43 error command "/usr/bin/node" "/usr/bin/npm" "install" "--save" "child_process" +44 error cwd /data/markdown-format-wdiff +45 error node -v v0.10.36 +46 error npm -v 1.4.28 +47 error code E404 +48 verbose exit [ 1, true ] diff --git a/package.json b/package.json new file mode 100644 index 0000000..796fbbd --- /dev/null +++ b/package.json @@ -0,0 +1,94 @@ +{ + "name": "markdown-format-wdiff", + "version": "0.0.0", + "main": "server/app.js", + "dependencies": { + "body-parser": "~1.5.0", + "composable-middleware": "^0.3.0", + "compression": "~1.0.1", + "connect-mongo": "^0.4.1", + "cookie-parser": "~1.0.1", + "errorhandler": "~1.0.0", + "express": "~4.0.0", + "express-jwt": "^0.1.3", + "express-session": "~1.0.2", + "jade": "~1.2.0", + "jsonwebtoken": "^0.3.0", + "lodash": "~2.4.1", + "method-override": "~1.0.0", + "mongoose": "~3.8.8", + "morgan": "~1.0.0", + "passport": "~0.2.0", + "passport-facebook": "latest", + "passport-google-oauth": "latest", + "passport-local": "~0.1.6", + "passport-twitter": "latest", + "serve-favicon": "~2.0.1", + "temp": "^0.8.1" + }, + "devDependencies": { + "grunt": "~0.4.4", + "grunt-autoprefixer": "~0.7.2", + "grunt-wiredep": "~1.8.0", + "grunt-concurrent": "~0.5.0", + "grunt-contrib-clean": "~0.5.0", + "grunt-contrib-concat": "~0.4.0", + "grunt-contrib-copy": "~0.5.0", + "grunt-contrib-cssmin": "~0.9.0", + "grunt-contrib-htmlmin": "~0.2.0", + "grunt-contrib-imagemin": "~0.7.1", + "grunt-contrib-jshint": "~0.10.0", + "grunt-contrib-uglify": "~0.4.0", + "grunt-contrib-watch": "~0.6.1", + "grunt-contrib-jade": "^0.11.0", + "grunt-google-cdn": "~0.4.0", + "grunt-newer": "~0.7.0", + "grunt-ng-annotate": "^0.2.3", + "grunt-rev": "~0.1.0", + "grunt-svgmin": "~0.4.0", + "grunt-usemin": "~2.1.1", + "grunt-env": "~0.4.1", + "grunt-node-inspector": "~0.1.5", + "grunt-nodemon": "~0.2.0", + "grunt-angular-templates": "^0.5.4", + "grunt-dom-munger": "^3.4.0", + "grunt-protractor-runner": "^1.1.0", + "grunt-asset-injector": "^0.1.0", + "grunt-karma": "~0.8.2", + "grunt-build-control": "DaftMonk/grunt-build-control", + "grunt-mocha-test": "~0.10.2", + "grunt-contrib-sass": "^0.7.3", + "jit-grunt": "^0.5.0", + "time-grunt": "~0.3.1", + "grunt-express-server": "~0.4.17", + "grunt-open": "~0.2.3", + "open": "~0.0.4", + "jshint-stylish": "~0.1.5", + "connect-livereload": "~0.4.0", + "karma-ng-scenario": "~0.1.0", + "karma-firefox-launcher": "~0.1.3", + "karma-script-launcher": "~0.1.0", + "karma-html2js-preprocessor": "~0.1.0", + "karma-ng-jade2js-preprocessor": "^0.1.2", + "karma-jasmine": "~0.1.5", + "karma-chrome-launcher": "~0.1.3", + "requirejs": "~2.1.11", + "karma-requirejs": "~0.2.1", + "karma-coffee-preprocessor": "~0.2.1", + "karma-jade-preprocessor": "0.0.11", + "karma-phantomjs-launcher": "~0.1.4", + "karma": "~0.12.9", + "karma-ng-html2js-preprocessor": "~0.1.0", + "supertest": "~0.11.0", + "should": "~3.3.1" + }, + "engines": { + "node": ">=0.10.0" + }, + "scripts": { + "start": "node server/app.js", + "test": "grunt test", + "update-webdriver": "node node_modules/grunt-protractor-runner/node_modules/protractor/bin/webdriver-manager update" + }, + "private": true +} diff --git a/protractor.conf.js b/protractor.conf.js new file mode 100644 index 0000000..cb66c67 --- /dev/null +++ b/protractor.conf.js @@ -0,0 +1,50 @@ +// Protractor configuration +// https://github.com/angular/protractor/blob/master/referenceConf.js + +'use strict'; + +exports.config = { + // The timeout for each script run on the browser. This should be longer + // than the maximum time your application needs to stabilize between tasks. + allScriptsTimeout: 110000, + + // A base URL for your application under test. Calls to protractor.get() + // with relative paths will be prepended with this. + baseUrl: 'http://localhost:' + (process.env.PORT || '9000'), + + // If true, only chromedriver will be started, not a standalone selenium. + // Tests for browsers other than chrome will not run. + chromeOnly: true, + + // list of files / patterns to load in the browser + specs: [ + 'e2e/**/*.spec.js' + ], + + // Patterns to exclude. + exclude: [], + + // ----- Capabilities to be passed to the webdriver instance ---- + // + // For a full list of available capabilities, see + // https://code.google.com/p/selenium/wiki/DesiredCapabilities + // and + // https://code.google.com/p/selenium/source/browse/javascript/webdriver/capabilities.js + capabilities: { + 'browserName': 'chrome' + }, + + // ----- The test framework ----- + // + // Jasmine and Cucumber are fully supported as a test and assertion framework. + // Mocha has limited beta support. You will need to include your own + // assertion framework if working with mocha. + framework: 'jasmine', + + // ----- Options to be passed to minijasminenode ----- + // + // See the full list at https://github.com/juliemr/minijasminenode + jasmineNodeOpts: { + defaultTimeoutInterval: 30000 + } +}; diff --git a/server/.jshintrc b/server/.jshintrc new file mode 100644 index 0000000..d7b958e --- /dev/null +++ b/server/.jshintrc @@ -0,0 +1,15 @@ +{ + "node": true, + "esnext": true, + "bitwise": true, + "eqeqeq": true, + "immed": true, + "latedef": "nofunc", + "newcap": true, + "noarg": true, + "regexp": true, + "undef": true, + "smarttabs": true, + "asi": true, + "debug": true +} diff --git a/server/.jshintrc-spec b/server/.jshintrc-spec new file mode 100644 index 0000000..b6b55cb --- /dev/null +++ b/server/.jshintrc-spec @@ -0,0 +1,11 @@ +{ + "extends": ".jshintrc", + "globals": { + "describe": true, + "it": true, + "before": true, + "beforeEach": true, + "after": true, + "afterEach": true + } +} diff --git a/server/api/thing/index.js b/server/api/thing/index.js new file mode 100644 index 0000000..845c9f0 --- /dev/null +++ b/server/api/thing/index.js @@ -0,0 +1,15 @@ +'use strict'; + +var express = require('express'); +var controller = require('./thing.controller'); + +var router = express.Router(); + +router.get('/', controller.index); +router.get('/:id', controller.show); +router.post('/', controller.create); +router.put('/:id', controller.update); +router.patch('/:id', controller.update); +router.delete('/:id', controller.destroy); + +module.exports = router; \ No newline at end of file diff --git a/server/api/thing/thing.controller.js b/server/api/thing/thing.controller.js new file mode 100644 index 0000000..be6541e --- /dev/null +++ b/server/api/thing/thing.controller.js @@ -0,0 +1,68 @@ +/** + * Using Rails-like standard naming convention for endpoints. + * GET /things -> index + * POST /things -> create + * GET /things/:id -> show + * PUT /things/:id -> update + * DELETE /things/:id -> destroy + */ + +'use strict'; + +var _ = require('lodash'); +var Thing = require('./thing.model'); + +// Get list of things +exports.index = function(req, res) { + Thing.find(function (err, things) { + if(err) { return handleError(res, err); } + return res.json(200, things); + }); +}; + +// Get a single thing +exports.show = function(req, res) { + Thing.findById(req.params.id, function (err, thing) { + if(err) { return handleError(res, err); } + if(!thing) { return res.send(404); } + return res.json(thing); + }); +}; + +// Creates a new thing in the DB. +exports.create = function(req, res) { + Thing.create(req.body, function(err, thing) { + if(err) { return handleError(res, err); } + return res.json(201, thing); + }); +}; + +// Updates an existing thing in the DB. +exports.update = function(req, res) { + if(req.body._id) { delete req.body._id; } + Thing.findById(req.params.id, function (err, thing) { + if (err) { return handleError(res, err); } + if(!thing) { return res.send(404); } + var updated = _.merge(thing, req.body); + updated.save(function (err) { + if (err) { return handleError(res, err); } + return res.json(200, thing); + }); + }); +}; + +// Deletes a thing from the DB. +exports.destroy = function(req, res) { + Thing.findById(req.params.id, function (err, thing) { + if(err) { return handleError(res, err); } + if(!thing) { return res.send(404); } + thing.remove(function(err) { + if(err) { return handleError(res, err); } + return res.send(204); + }); + }); +}; + +function handleError(res, err) { + return res.send(500, err); +} \ No newline at end of file diff --git a/server/api/thing/thing.model.js b/server/api/thing/thing.model.js new file mode 100644 index 0000000..ed857cd --- /dev/null +++ b/server/api/thing/thing.model.js @@ -0,0 +1,12 @@ +'use strict'; + +var mongoose = require('mongoose'), + Schema = mongoose.Schema; + +var ThingSchema = new Schema({ + name: String, + info: String, + active: Boolean +}); + +module.exports = mongoose.model('Thing', ThingSchema); \ No newline at end of file diff --git a/server/api/thing/thing.spec.js b/server/api/thing/thing.spec.js new file mode 100644 index 0000000..17c8c6c --- /dev/null +++ b/server/api/thing/thing.spec.js @@ -0,0 +1,20 @@ +'use strict'; + +var should = require('should'); +var app = require('../../app'); +var request = require('supertest'); + +describe('GET /api/things', function() { + + it('should respond with JSON array', function(done) { + request(app) + .get('/api/things') + .expect(200) + .expect('Content-Type', /json/) + .end(function(err, res) { + if (err) return done(err); + res.body.should.be.instanceof(Array); + done(); + }); + }); +}); diff --git a/server/api/user/index.js b/server/api/user/index.js new file mode 100644 index 0000000..48567e4 --- /dev/null +++ b/server/api/user/index.js @@ -0,0 +1,17 @@ +'use strict'; + +var express = require('express'); +var controller = require('./user.controller'); +var config = require('../../config/environment'); +var auth = require('../../auth/auth.service'); + +var router = express.Router(); + +router.get('/', auth.hasRole('admin'), controller.index); +router.delete('/:id', auth.hasRole('admin'), controller.destroy); +router.get('/me', auth.isAuthenticated(), controller.me); +router.put('/:id/password', auth.isAuthenticated(), controller.changePassword); +router.get('/:id', auth.isAuthenticated(), controller.show); +router.post('/', controller.create); + +module.exports = router; diff --git a/server/api/user/user.controller.js b/server/api/user/user.controller.js new file mode 100644 index 0000000..f4cd10c --- /dev/null +++ b/server/api/user/user.controller.js @@ -0,0 +1,101 @@ +'use strict'; + +var User = require('./user.model'); +var passport = require('passport'); +var config = require('../../config/environment'); +var jwt = require('jsonwebtoken'); + +var validationError = function(res, err) { + return res.json(422, err); +}; + +/** + * Get list of users + * restriction: 'admin' + */ +exports.index = function(req, res) { + User.find({}, '-salt -hashedPassword', function (err, users) { + if(err) return res.send(500, err); + res.json(200, users); + }); +}; + +/** + * Creates a new user + */ +exports.create = function (req, res, next) { + var newUser = new User(req.body); + newUser.provider = 'local'; + newUser.role = 'user'; + newUser.save(function(err, user) { + if (err) return validationError(res, err); + var token = jwt.sign({_id: user._id }, config.secrets.session, { expiresInMinutes: 60*5 }); + res.json({ token: token }); + }); +}; + +/** + * Get a single user + */ +exports.show = function (req, res, next) { + var userId = req.params.id; + + User.findById(userId, function (err, user) { + if (err) return next(err); + if (!user) return res.send(401); + res.json(user.profile); + }); +}; + +/** + * Deletes a user + * restriction: 'admin' + */ +exports.destroy = function(req, res) { + User.findByIdAndRemove(req.params.id, function(err, user) { + if(err) return res.send(500, err); + return res.send(204); + }); +}; + +/** + * Change a users password + */ +exports.changePassword = function(req, res, next) { + var userId = req.user._id; + var oldPass = String(req.body.oldPassword); + var newPass = String(req.body.newPassword); + + User.findById(userId, function (err, user) { + if(user.authenticate(oldPass)) { + user.password = newPass; + user.save(function(err) { + if (err) return validationError(res, err); + res.send(200); + }); + } else { + res.send(403); + } + }); +}; + +/** + * Get my info + */ +exports.me = function(req, res, next) { + var userId = req.user._id; + User.findOne({ + _id: userId + }, '-salt -hashedPassword', function(err, user) { // don't ever give out the password or salt + if (err) return next(err); + if (!user) return res.json(401); + res.json(user); + }); +}; + +/** + * Authentication callback + */ +exports.authCallback = function(req, res, next) { + res.redirect('/'); +}; diff --git a/server/api/user/user.model.js b/server/api/user/user.model.js new file mode 100644 index 0000000..603f016 --- /dev/null +++ b/server/api/user/user.model.js @@ -0,0 +1,149 @@ +'use strict'; + +var mongoose = require('mongoose'); +var Schema = mongoose.Schema; +var crypto = require('crypto'); +var authTypes = ['github', 'twitter', 'facebook', 'google']; + +var UserSchema = new Schema({ + name: String, + email: { type: String, lowercase: true }, + role: { + type: String, + default: 'user' + }, + hashedPassword: String, + provider: String, + salt: String, + facebook: {}, + twitter: {}, + google: {}, + github: {} +}); + +/** + * Virtuals + */ +UserSchema + .virtual('password') + .set(function(password) { + this._password = password; + this.salt = this.makeSalt(); + this.hashedPassword = this.encryptPassword(password); + }) + .get(function() { + return this._password; + }); + +// Public profile information +UserSchema + .virtual('profile') + .get(function() { + return { + 'name': this.name, + 'role': this.role + }; + }); + +// Non-sensitive info we'll be putting in the token +UserSchema + .virtual('token') + .get(function() { + return { + '_id': this._id, + 'role': this.role + }; + }); + +/** + * Validations + */ + +// Validate empty email +UserSchema + .path('email') + .validate(function(email) { + if (authTypes.indexOf(this.provider) !== -1) return true; + return email.length; + }, 'Email cannot be blank'); + +// Validate empty password +UserSchema + .path('hashedPassword') + .validate(function(hashedPassword) { + if (authTypes.indexOf(this.provider) !== -1) return true; + return hashedPassword.length; + }, 'Password cannot be blank'); + +// Validate email is not taken +UserSchema + .path('email') + .validate(function(value, respond) { + var self = this; + this.constructor.findOne({email: value}, function(err, user) { + if(err) throw err; + if(user) { + if(self.id === user.id) return respond(true); + return respond(false); + } + respond(true); + }); +}, 'The specified email address is already in use.'); + +var validatePresenceOf = function(value) { + return value && value.length; +}; + +/** + * Pre-save hook + */ +UserSchema + .pre('save', function(next) { + if (!this.isNew) return next(); + + if (!validatePresenceOf(this.hashedPassword) && authTypes.indexOf(this.provider) === -1) + next(new Error('Invalid password')); + else + next(); + }); + +/** + * Methods + */ +UserSchema.methods = { + /** + * Authenticate - check if the passwords are the same + * + * @param {String} plainText + * @return {Boolean} + * @api public + */ + authenticate: function(plainText) { + return this.encryptPassword(plainText) === this.hashedPassword; + }, + + /** + * Make salt + * + * @return {String} + * @api public + */ + makeSalt: function() { + return crypto.randomBytes(16).toString('base64'); + }, + + /** + * Encrypt password + * + * @param {String} password + * @return {String} + * @api public + */ + encryptPassword: function(password) { + if (!password || !this.salt) return ''; + var salt = new Buffer(this.salt, 'base64'); + return crypto.pbkdf2Sync(password, salt, 10000, 64).toString('base64'); + } +}; + +module.exports = mongoose.model('User', UserSchema); diff --git a/server/api/user/user.model.spec.js b/server/api/user/user.model.spec.js new file mode 100644 index 0000000..257c95b --- /dev/null +++ b/server/api/user/user.model.spec.js @@ -0,0 +1,60 @@ +'use strict'; + +var should = require('should'); +var app = require('../../app'); +var User = require('./user.model'); + +var user = new User({ + provider: 'local', + name: 'Fake User', + email: 'test@test.com', + password: 'password' +}); + +describe('User Model', function() { + before(function(done) { + // Clear users before testing + User.remove().exec().then(function() { + done(); + }); + }); + + afterEach(function(done) { + User.remove().exec().then(function() { + done(); + }); + }); + + it('should begin with no users', function(done) { + User.find({}, function(err, users) { + users.should.have.length(0); + done(); + }); + }); + + it('should fail when saving a duplicate user', function(done) { + user.save(function() { + var userDup = new User(user); + userDup.save(function(err) { + should.exist(err); + done(); + }); + }); + }); + + it('should fail when saving without an email', function(done) { + user.email = ''; + user.save(function(err) { + should.exist(err); + done(); + }); + }); + + it("should authenticate user if password is valid", function() { + return user.authenticate('password').should.be.true; + }); + + it("should not authenticate user if password is invalid", function() { + return user.authenticate('blah').should.not.be.true; + }); +}); diff --git a/server/api/wdiff/index.js b/server/api/wdiff/index.js new file mode 100644 index 0000000..071c814 --- /dev/null +++ b/server/api/wdiff/index.js @@ -0,0 +1,12 @@ +'use strict'; + +var express = require('express'); +var controller = require('./wdiff.controller'); + +var router = express.Router(); + +router.post('/', controller.compare); +router.post('/markdown', controller.compareMarkdown); + + +module.exports = router; \ No newline at end of file diff --git a/server/api/wdiff/wdiff.controller.js b/server/api/wdiff/wdiff.controller.js new file mode 100644 index 0000000..02b7d9e --- /dev/null +++ b/server/api/wdiff/wdiff.controller.js @@ -0,0 +1,94 @@ + + +'use strict'; + +var _ = require('lodash'), + temp = require('temp'), + fs = require('fs'), + exec = require('child_process').exec; + +// Automatically track and cleanup files at exit +temp.track(); + +exports.compare = function(req, res) { + doCompare(req, res, false); +}; + +exports.compareMarkdown = function(req, res) { + doCompare(req, res, true); +}; + +// Perform a comparison +// The request should be a json object with two string fields: 'a' and 'b' + function doCompare(req, res, isMarkdown) { + + //check for properly formatted request + if (req.headers["content-type"].toLowerCase() != "application/json") + return handleError(res, {error: "Content-type must be 'application/json'"}); + + if (!req.body.a || !req.body.b) + return handleError(res, {error: "Request data should be of the form {a:'text a', b:'text b'}"}); + + var a = req.body.a; + var b = req.body.b; + + //!!! this nested file-open is not a good pattern + // better would be to use promises and write the two files asynchronously + + // open the first file + temp.open('wdiffa-', function(err, filea) { + //handle errors + if (err) + return handleError(res, err); + + //write the string to the file + fs.write(filea.fd, a); + + //close the file + fs.close(filea.fd, function(err) { + if (err) + return handleError(res, err); + + //open the second file + temp.open('wdiffa-', function(err, fileb) { + if (err) + return handleError(res, err); + + //write the string to the file + fs.write(fileb.fd, b); + + //close the file + fs.close(fileb.fd, function(err) { + if (err) + return handleError(res, err); + + var cmd = "./bin/wdiff " + filea.path + " " +fileb.path; + exec(cmd, function(err, stdout) { + + if (err && err.code!=1 && err.code!=0) { + return handleError(res,err); + } + + //if no difference was found by wdiff, err.code will be 0 + var wdiffSame; + wdiffSame = (err && err.code == 0) ? true:false; + + //sub del and ins + var markdown = stdout; + markdown = markdown.replace(/\[-/g, ''); + markdown = markdown.replace(/-\]/g, ''); + markdown = markdown.replace(/{\+/g, ''); + markdown = markdown.replace(/\+}/g, ''); + + var resData = {wdiff:stdout, same: wdiffSame, markdown: markdown}; + }); + }); + }); + }); + }); +} + + +function handleError(res, err) { + return res.send(500, err); +} \ No newline at end of file diff --git a/server/app.js b/server/app.js new file mode 100644 index 0000000..82acdba --- /dev/null +++ b/server/app.js @@ -0,0 +1,32 @@ +/** + * Main application file + */ + +'use strict'; + +// Set default node environment to development +process.env.NODE_ENV = process.env.NODE_ENV || 'development'; + +var express = require('express'); +var mongoose = require('mongoose'); +var config = require('./config/environment'); + +// Connect to database +mongoose.connect(config.mongo.uri, config.mongo.options); + +// Populate DB with sample data +if(config.seedDB) { require('./config/seed'); } + +// Setup server +var app = express(); +var server = require('http').createServer(app); +require('./config/express')(app); +require('./routes')(app); + +// Start server +server.listen(config.port, config.ip, function () { + console.log('Express server listening on %d, in %s mode', config.port, app.get('env')); +}); + +// Expose app +exports = module.exports = app; \ No newline at end of file diff --git a/server/auth/auth.service.js b/server/auth/auth.service.js new file mode 100644 index 0000000..38ec343 --- /dev/null +++ b/server/auth/auth.service.js @@ -0,0 +1,76 @@ +'use strict'; + +var mongoose = require('mongoose'); +var passport = require('passport'); +var config = require('../config/environment'); +var jwt = require('jsonwebtoken'); +var expressJwt = require('express-jwt'); +var compose = require('composable-middleware'); +var User = require('../api/user/user.model'); +var validateJwt = expressJwt({ secret: config.secrets.session }); + +/** + * Attaches the user object to the request if authenticated + * Otherwise returns 403 + */ +function isAuthenticated() { + return compose() + // Validate jwt + .use(function(req, res, next) { + // allow access_token to be passed through query parameter as well + if(req.query && req.query.hasOwnProperty('access_token')) { + req.headers.authorization = 'Bearer ' + req.query.access_token; + } + validateJwt(req, res, next); + }) + // Attach user to request + .use(function(req, res, next) { + User.findById(req.user._id, function (err, user) { + if (err) return next(err); + if (!user) return res.send(401); + + req.user = user; + next(); + }); + }); +} + +/** + * Checks if the user role meets the minimum requirements of the route + */ +function hasRole(roleRequired) { + if (!roleRequired) throw new Error('Required role needs to be set'); + + return compose() + .use(isAuthenticated()) + .use(function meetsRequirements(req, res, next) { + if (config.userRoles.indexOf(req.user.role) >= config.userRoles.indexOf(roleRequired)) { + next(); + } + else { + res.send(403); + } + }); +} + +/** + * Returns a jwt token signed by the app secret + */ +function signToken(id) { + return jwt.sign({ _id: id }, config.secrets.session, { expiresInMinutes: 60*5 }); +} + +/** + * Set token cookie directly for oAuth strategies + */ +function setTokenCookie(req, res) { + if (!req.user) return res.json(404, { message: 'Something went wrong, please try again.'}); + var token = signToken(req.user._id, req.user.role); + res.cookie('token', JSON.stringify(token)); + res.redirect('/'); +} + +exports.isAuthenticated = isAuthenticated; +exports.hasRole = hasRole; +exports.signToken = signToken; +exports.setTokenCookie = setTokenCookie; \ No newline at end of file diff --git a/server/auth/facebook/index.js b/server/auth/facebook/index.js new file mode 100644 index 0000000..4a6f878 --- /dev/null +++ b/server/auth/facebook/index.js @@ -0,0 +1,21 @@ +'use strict'; + +var express = require('express'); +var passport = require('passport'); +var auth = require('../auth.service'); + +var router = express.Router(); + +router + .get('/', passport.authenticate('facebook', { + scope: ['email', 'user_about_me'], + failureRedirect: '/signup', + session: false + })) + + .get('/callback', passport.authenticate('facebook', { + failureRedirect: '/signup', + session: false + }), auth.setTokenCookie); + +module.exports = router; \ No newline at end of file diff --git a/server/auth/facebook/passport.js b/server/auth/facebook/passport.js new file mode 100644 index 0000000..90ae489 --- /dev/null +++ b/server/auth/facebook/passport.js @@ -0,0 +1,37 @@ +var passport = require('passport'); +var FacebookStrategy = require('passport-facebook').Strategy; + +exports.setup = function (User, config) { + passport.use(new FacebookStrategy({ + clientID: config.facebook.clientID, + clientSecret: config.facebook.clientSecret, + callbackURL: config.facebook.callbackURL + }, + function(accessToken, refreshToken, profile, done) { + User.findOne({ + 'facebook.id': profile.id + }, + function(err, user) { + if (err) { + return done(err); + } + if (!user) { + user = new User({ + name: profile.displayName, + email: profile.emails[0].value, + role: 'user', + username: profile.username, + provider: 'facebook', + facebook: profile._json + }); + user.save(function(err) { + if (err) done(err); + return done(err, user); + }); + } else { + return done(err, user); + } + }) + } + )); +}; \ No newline at end of file diff --git a/server/auth/google/index.js b/server/auth/google/index.js new file mode 100644 index 0000000..9b1ce39 --- /dev/null +++ b/server/auth/google/index.js @@ -0,0 +1,24 @@ +'use strict'; + +var express = require('express'); +var passport = require('passport'); +var auth = require('../auth.service'); + +var router = express.Router(); + +router + .get('/', passport.authenticate('google', { + failureRedirect: '/signup', + scope: [ + 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/userinfo.email' + ], + session: false + })) + + .get('/callback', passport.authenticate('google', { + failureRedirect: '/signup', + session: false + }), auth.setTokenCookie); + +module.exports = router; \ No newline at end of file diff --git a/server/auth/google/passport.js b/server/auth/google/passport.js new file mode 100644 index 0000000..d304e8a --- /dev/null +++ b/server/auth/google/passport.js @@ -0,0 +1,33 @@ +var passport = require('passport'); +var GoogleStrategy = require('passport-google-oauth').OAuth2Strategy; + +exports.setup = function (User, config) { + passport.use(new GoogleStrategy({ + clientID: config.google.clientID, + clientSecret: config.google.clientSecret, + callbackURL: config.google.callbackURL + }, + function(accessToken, refreshToken, profile, done) { + User.findOne({ + 'google.id': profile.id + }, function(err, user) { + if (!user) { + user = new User({ + name: profile.displayName, + email: profile.emails[0].value, + role: 'user', + username: profile.username, + provider: 'google', + google: profile._json + }); + user.save(function(err) { + if (err) done(err); + return done(err, user); + }); + } else { + return done(err, user); + } + }); + } + )); +}; diff --git a/server/auth/index.js b/server/auth/index.js new file mode 100644 index 0000000..3a5bf70 --- /dev/null +++ b/server/auth/index.js @@ -0,0 +1,21 @@ +'use strict'; + +var express = require('express'); +var passport = require('passport'); +var config = require('../config/environment'); +var User = require('../api/user/user.model'); + +// Passport Configuration +require('./local/passport').setup(User, config); +require('./facebook/passport').setup(User, config); +require('./google/passport').setup(User, config); +require('./twitter/passport').setup(User, config); + +var router = express.Router(); + +router.use('/local', require('./local')); +router.use('/facebook', require('./facebook')); +router.use('/twitter', require('./twitter')); +router.use('/google', require('./google')); + +module.exports = router; \ No newline at end of file diff --git a/server/auth/local/index.js b/server/auth/local/index.js new file mode 100644 index 0000000..8bf88a0 --- /dev/null +++ b/server/auth/local/index.js @@ -0,0 +1,20 @@ +'use strict'; + +var express = require('express'); +var passport = require('passport'); +var auth = require('../auth.service'); + +var router = express.Router(); + +router.post('/', function(req, res, next) { + passport.authenticate('local', function (err, user, info) { + var error = err || info; + if (error) return res.json(401, error); + if (!user) return res.json(404, {message: 'Something went wrong, please try again.'}); + + var token = auth.signToken(user._id, user.role); + res.json({token: token}); + })(req, res, next) +}); + +module.exports = router; \ No newline at end of file diff --git a/server/auth/local/passport.js b/server/auth/local/passport.js new file mode 100644 index 0000000..ac82b42 --- /dev/null +++ b/server/auth/local/passport.js @@ -0,0 +1,25 @@ +var passport = require('passport'); +var LocalStrategy = require('passport-local').Strategy; + +exports.setup = function (User, config) { + passport.use(new LocalStrategy({ + usernameField: 'email', + passwordField: 'password' // this is the virtual field on the model + }, + function(email, password, done) { + User.findOne({ + email: email.toLowerCase() + }, function(err, user) { + if (err) return done(err); + + if (!user) { + return done(null, false, { message: 'This email is not registered.' }); + } + if (!user.authenticate(password)) { + return done(null, false, { message: 'This password is not correct.' }); + } + return done(null, user); + }); + } + )); +}; \ No newline at end of file diff --git a/server/auth/twitter/index.js b/server/auth/twitter/index.js new file mode 100644 index 0000000..8360247 --- /dev/null +++ b/server/auth/twitter/index.js @@ -0,0 +1,20 @@ +'use strict'; + +var express = require('express'); +var passport = require('passport'); +var auth = require('../auth.service'); + +var router = express.Router(); + +router + .get('/', passport.authenticate('twitter', { + failureRedirect: '/signup', + session: false + })) + + .get('/callback', passport.authenticate('twitter', { + failureRedirect: '/signup', + session: false + }), auth.setTokenCookie); + +module.exports = router; \ No newline at end of file diff --git a/server/auth/twitter/passport.js b/server/auth/twitter/passport.js new file mode 100644 index 0000000..a2eb4a5 --- /dev/null +++ b/server/auth/twitter/passport.js @@ -0,0 +1,35 @@ +exports.setup = function (User, config) { + var passport = require('passport'); + var TwitterStrategy = require('passport-twitter').Strategy; + + passport.use(new TwitterStrategy({ + consumerKey: config.twitter.clientID, + consumerSecret: config.twitter.clientSecret, + callbackURL: config.twitter.callbackURL + }, + function(token, tokenSecret, profile, done) { + User.findOne({ + 'twitter.id_str': profile.id + }, function(err, user) { + if (err) { + return done(err); + } + if (!user) { + user = new User({ + name: profile.displayName, + username: profile.username, + role: 'user', + provider: 'twitter', + twitter: profile._json + }); + user.save(function(err) { + if (err) return done(err); + return done(err, user); + }); + } else { + return done(err, user); + } + }); + } + )); +}; \ No newline at end of file diff --git a/server/components/errors/index.js b/server/components/errors/index.js new file mode 100644 index 0000000..4c5a57c --- /dev/null +++ b/server/components/errors/index.js @@ -0,0 +1,20 @@ +/** + * Error responses + */ + +'use strict'; + +module.exports[404] = function pageNotFound(req, res) { + var viewFilePath = '404'; + var statusCode = 404; + var result = { + status: statusCode + }; + + res.status(result.status); + res.render(viewFilePath, function (err) { + if (err) { return res.json(result, result.status); } + + res.render(viewFilePath); + }); +}; diff --git a/server/config/environment/development.js b/server/config/environment/development.js new file mode 100644 index 0000000..f4b1194 --- /dev/null +++ b/server/config/environment/development.js @@ -0,0 +1,12 @@ +'use strict'; + +// Development specific configuration +// ================================== +module.exports = { + // MongoDB connection options + mongo: { + uri: 'mongodb://mongodb/markdownformatwdiff-dev' + }, + + seedDB: false +}; diff --git a/server/config/environment/index.js b/server/config/environment/index.js new file mode 100644 index 0000000..fdfe251 --- /dev/null +++ b/server/config/environment/index.js @@ -0,0 +1,67 @@ +'use strict'; + +var path = require('path'); +var _ = require('lodash'); + +function requiredProcessEnv(name) { + if(!process.env[name]) { + throw new Error('You must set the ' + name + ' environment variable'); + } + return process.env[name]; +} + +// All configurations will extend these options +// ============================================ +var all = { + env: process.env.NODE_ENV, + + // Root path of server + root: path.normalize(__dirname + '/../../..'), + + // Server port + port: process.env.PORT || 9000, + + // Should we populate the DB with sample data? + seedDB: false, + + // Secret for session, you will want to change this and make it an environment variable + secrets: { + session: 'markdown-format-wdiff-secret' + }, + + // List of user roles + userRoles: ['guest', 'user', 'admin'], + + // MongoDB connection options + mongo: { + options: { + db: { + safe: true + } + } + }, + + facebook: { + clientID: process.env.FACEBOOK_ID || 'id', + clientSecret: process.env.FACEBOOK_SECRET || 'secret', + callbackURL: (process.env.DOMAIN || '') + '/auth/facebook/callback' + }, + + twitter: { + clientID: process.env.TWITTER_ID || 'id', + clientSecret: process.env.TWITTER_SECRET || 'secret', + callbackURL: (process.env.DOMAIN || '') + '/auth/twitter/callback' + }, + + google: { + clientID: process.env.GOOGLE_ID || 'id', + clientSecret: process.env.GOOGLE_SECRET || 'secret', + callbackURL: (process.env.DOMAIN || '') + '/auth/google/callback' + } +}; + +// Export the config object based on the NODE_ENV +// ============================================== +module.exports = _.merge( + all, + require('./' + process.env.NODE_ENV + '.js') || {}); \ No newline at end of file diff --git a/server/config/environment/production.js b/server/config/environment/production.js new file mode 100644 index 0000000..3014393 --- /dev/null +++ b/server/config/environment/production.js @@ -0,0 +1,23 @@ +'use strict'; + +// Production specific configuration +// ================================= +module.exports = { + // Server IP + ip: process.env.OPENSHIFT_NODEJS_IP || + process.env.IP || + undefined, + + // Server port + port: process.env.OPENSHIFT_NODEJS_PORT || + process.env.PORT || + 8080, + + // MongoDB connection options + mongo: { + uri: process.env.MONGOLAB_URI || + process.env.MONGOHQ_URL || + process.env.OPENSHIFT_MONGODB_DB_URL+process.env.OPENSHIFT_APP_NAME || + 'mongodb://localhost/markdownformatwdiff' + } +}; \ No newline at end of file diff --git a/server/config/environment/test.js b/server/config/environment/test.js new file mode 100644 index 0000000..f97568f --- /dev/null +++ b/server/config/environment/test.js @@ -0,0 +1,10 @@ +'use strict'; + +// Test specific configuration +// =========================== +module.exports = { + // MongoDB connection options + mongo: { + uri: 'mongodb://localhost/markdownformatwdiff-test' + } +}; \ No newline at end of file diff --git a/server/config/express.js b/server/config/express.js new file mode 100644 index 0000000..02348fe --- /dev/null +++ b/server/config/express.js @@ -0,0 +1,58 @@ +/** + * Express configuration + */ + +'use strict'; + +var express = require('express'); +var favicon = require('serve-favicon'); +var morgan = require('morgan'); +var compression = require('compression'); +var bodyParser = require('body-parser'); +var methodOverride = require('method-override'); +var cookieParser = require('cookie-parser'); +var errorHandler = require('errorhandler'); +var path = require('path'); +var config = require('./environment'); +var passport = require('passport'); +var session = require('express-session'); +var mongoStore = require('connect-mongo')(session); +var mongoose = require('mongoose'); + +module.exports = function(app) { + var env = app.get('env'); + + app.set('views', config.root + '/server/views'); + app.set('view engine', 'jade'); + app.use(compression()); + app.use(bodyParser.urlencoded({ extended: false })); + app.use(bodyParser.json()); + app.use(methodOverride()); + app.use(cookieParser()); + app.use(passport.initialize()); + + // Persist sessions with mongoStore + // We need to enable sessions for passport twitter because its an oauth 1.0 strategy + app.use(session({ + secret: config.secrets.session, + resave: true, + saveUninitialized: true, + store: new mongoStore({ mongoose_connection: mongoose.connection }) + })); + + if ('production' === env) { + app.use(favicon(path.join(config.root, 'public', 'favicon.ico'))); + app.use(express.static(path.join(config.root, 'public'))); + app.set('appPath', config.root + '/public'); + app.use(morgan('dev')); + } + + if ('development' === env || 'test' === env) { + app.use(require('connect-livereload')()); + app.use(express.static(path.join(config.root, '.tmp'))); + app.use(express.static(path.join(config.root, 'client'))); + app.set('appPath', 'client'); + app.use(morgan('dev')); + app.use(errorHandler()); // Error handler - has to be last + } +}; \ No newline at end of file diff --git a/server/config/local.env.sample.js b/server/config/local.env.sample.js new file mode 100644 index 0000000..d9cda15 --- /dev/null +++ b/server/config/local.env.sample.js @@ -0,0 +1,23 @@ +'use strict'; + +// Use local.env.js for environment variables that grunt will set when the server starts locally. +// Use for your api keys, secrets, etc. This file should not be tracked by git. +// +// You will need to set these on the server you deploy to. + +module.exports = { + DOMAIN: 'http://localhost:9000', + SESSION_SECRET: 'markdownformatwdiff-secret', + + FACEBOOK_ID: 'app-id', + FACEBOOK_SECRET: 'secret', + + TWITTER_ID: 'app-id', + TWITTER_SECRET: 'secret', + + GOOGLE_ID: 'app-id', + GOOGLE_SECRET: 'secret', + + // Control debug level for modules using visionmedia/debug + DEBUG: '' +}; diff --git a/server/config/seed.js b/server/config/seed.js new file mode 100644 index 0000000..888dcc5 --- /dev/null +++ b/server/config/seed.js @@ -0,0 +1,49 @@ +/** + * Populate DB with sample data on server start + * to disable, edit config/environment/index.js, and set `seedDB: false` + */ + +'use strict'; + +var Thing = require('../api/thing/thing.model'); +var User = require('../api/user/user.model'); + +Thing.find({}).remove(function() { + Thing.create({ + name : 'Development Tools', + info : 'Integration with popular tools such as Bower, Grunt, Karma, Mocha, JSHint, Node Inspector, Livereload, Protractor, Jade, Stylus, Sass, CoffeeScript, and Less.' + }, { + name : 'Server and Client integration', + info : 'Built with a powerful and fun stack: MongoDB, Express, AngularJS, and Node.' + }, { + name : 'Smart Build System', + info : 'Build system ignores `spec` files, allowing you to keep tests alongside code. Automatic injection of scripts and styles into your index.html' + }, { + name : 'Modular Structure', + info : 'Best practice client and server structures allow for more code reusability and maximum scalability' + }, { + name : 'Optimized Build', + info : 'Build process packs up your templates as a single JavaScript payload, minifies your scripts/css/images, and rewrites asset names for caching.' + },{ + name : 'Deployment Ready', + info : 'Easily deploy your app to Heroku or Openshift with the heroku and openshift subgenerators' + }); +}); + +User.find({}).remove(function() { + User.create({ + provider: 'local', + name: 'Test User', + email: 'test@test.com', + password: 'test' + }, { + provider: 'local', + role: 'admin', + name: 'Admin', + email: 'admin@admin.com', + password: 'admin' + }, function() { + console.log('finished populating users'); + } + ); +}); \ No newline at end of file diff --git a/server/routes.js b/server/routes.js new file mode 100644 index 0000000..26f6ead --- /dev/null +++ b/server/routes.js @@ -0,0 +1,27 @@ +/** + * Main application routes + */ + +'use strict'; + +var errors = require('./components/errors'); + +module.exports = function(app) { + + // Insert routes below + app.use('/api/things', require('./api/thing')); + app.use('/api/wdiff', require('./api/wdiff')); + app.use('/api/users', require('./api/user')); + + app.use('/auth', require('./auth')); + + // All undefined asset or api routes should return a 404 + app.route('/:url(api|auth|components|app|bower_components|assets)/*') + .get(errors[404]); + + // All other routes should redirect to the index.html + app.route('/*') + .get(function(req, res) { + res.sendfile(app.get('appPath') + '/index.html'); + }); +}; diff --git a/server/views/404.jade b/server/views/404.jade new file mode 100644 index 0000000..b5735b4 --- /dev/null +++ b/server/views/404.jade @@ -0,0 +1,133 @@ +doctype html +html(lang='en') +head + meta(charset='utf-8') + title Page Not Found :( + style. + ::-moz-selection { + background: #b3d4fc; + text-shadow: none; + } + ::selection { + background: #b3d4fc; + text-shadow: none; + } + html { + padding: 30px 10px; + font-size: 20px; + line-height: 1.4; + color: #737373; + background: #f0f0f0; + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; + } + html, + input { + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + } + body { + max-width: 500px; + _width: 500px; + padding: 30px 20px 50px; + border: 1px solid #b3b3b3; + border-radius: 4px; + margin: 0 auto; + box-shadow: 0 1px 10px #a7a7a7, inset 0 1px 0 #fff; + background: #fcfcfc; + } + h1 { + margin: 0 10px; + font-size: 50px; + text-align: center; + } + h1 span { + color: #bbb; + } + h3 { + margin: 1.5em 0 0.5em; + } + p { + margin: 1em 0; + } + ul { + padding: 0 0 0 40px; + margin: 1em 0; + } + .container { + max-width: 380px; + _width: 380px; + margin: 0 auto; + } + /* google search */ + #goog-fixurl ul { + list-style: none; + padding: 0; + margin: 0; + } + #goog-fixurl form { + margin: 0; + } + #goog-wm-qt, + #goog-wm-sb { + border: 1px solid #bbb; + font-size: 16px; + line-height: normal; + vertical-align: top; + color: #444; + border-radius: 2px; + } + #goog-wm-qt { + width: 220px; + height: 20px; + padding: 5px; + margin: 5px 10px 0 0; + box-shadow: inset 0 1px 1px #ccc; + } + #goog-wm-sb { + display: inline-block; + height: 32px; + padding: 0 10px; + margin: 5px 0 0; + white-space: nowrap; + cursor: pointer; + background-color: #f5f5f5; + background-image: -webkit-linear-gradient(rgba(255,255,255,0), #f1f1f1); + background-image: -moz-linear-gradient(rgba(255,255,255,0), #f1f1f1); + background-image: -ms-linear-gradient(rgba(255,255,255,0), #f1f1f1); + background-image: -o-linear-gradient(rgba(255,255,255,0), #f1f1f1); + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + *overflow: visible; + *display: inline; + *zoom: 1; + } + #goog-wm-sb:hover, + #goog-wm-sb:focus { + border-color: #aaa; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1); + background-color: #f8f8f8; + } + #goog-wm-qt:hover, + #goog-wm-qt:focus { + border-color: #105cb6; + outline: 0; + color: #222; + } + input::-moz-focus-inner { + padding: 0; + border: 0; + } + body + .container + h1 + | Not found + span :( + p Sorry, but the page you were trying to view does not exist. + p It looks like this was the result of either: + ul + li a mistyped address + li an out-of-date link + script. + var GOOG_FIXURL_LANG = (navigator.language || '').slice(0,2),GOOG_FIXURL_SITE = location.host; + script(src='//linkhelp.clients.google.com/tbproxy/lh/wm/fixurl.js') diff --git a/test/a.txt b/test/a.txt new file mode 100644 index 0000000..34c92c2 --- /dev/null +++ b/test/a.txt @@ -0,0 +1 @@ +lorem ipsum \ No newline at end of file diff --git a/test/b.txt b/test/b.txt new file mode 100644 index 0000000..5d3986c --- /dev/null +++ b/test/b.txt @@ -0,0 +1 @@ +lorum ipsum \ No newline at end of file