diff --git a/README.md b/README.md index 15ebe71a..9caba2a6 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Features - Tag system with relations - Multi-users ACL system - Audit log +- Comments - Document sharing by URL - RESTful Web API - Fully featured Android client diff --git a/docs-android/app/app.iml b/docs-android/app/app.iml index afd2300c..704ef345 100644 --- a/docs-android/app/app.iml +++ b/docs-android/app/app.iml @@ -71,12 +71,11 @@ - + - + - @@ -84,17 +83,23 @@ - - - + + + + + - + + + + + @@ -112,12 +117,12 @@ - + \ No newline at end of file diff --git a/docs-android/app/build.gradle b/docs-android/app/build.gradle index e162b17a..44f07f11 100644 --- a/docs-android/app/build.gradle +++ b/docs-android/app/build.gradle @@ -1,20 +1,20 @@ buildscript { repositories { - mavenCentral() + jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:1.3.0' + classpath 'com.android.tools.build:gradle:2.0.0-alpha1' } } apply plugin: 'com.android.application' repositories { - mavenCentral() + jcenter() } android { compileSdkVersion 22 - buildToolsVersion "23.0.2" + buildToolsVersion '23.0.2' defaultConfig { minSdkVersion 14 @@ -54,6 +54,6 @@ dependencies { compile 'com.android.support:recyclerview-v7:22.+' compile 'com.loopj.android:android-async-http:1.4.6' compile 'it.sephiroth.android.library.imagezoom:imagezoom:1.0.5' - compile 'de.greenrobot:eventbus:2.4.0' + compile 'de.greenrobot:eventbus:2.4.1' compile 'com.shamanland:fab:0.0.6' } diff --git a/docs-android/app/src/main/java/com/sismics/docs/MainApplication.java b/docs-android/app/src/main/java/com/sismics/docs/MainApplication.java index 340abdbf..6d636c78 100644 --- a/docs-android/app/src/main/java/com/sismics/docs/MainApplication.java +++ b/docs-android/app/src/main/java/com/sismics/docs/MainApplication.java @@ -20,7 +20,6 @@ public class MainApplication extends Application { JSONObject json = PreferenceUtil.getCachedJson(getApplicationContext(), PreferenceUtil.PREF_CACHED_USER_INFO_JSON); ApplicationContext.getInstance().setUserInfo(getApplicationContext(), json); - // TODO google docs app: right drawer with all actions, with acls, with deep metadatas // TODO Provide documents to intent action get content super.onCreate(); @@ -28,6 +27,7 @@ public class MainApplication extends Application { @Override public void onLowMemory() { + super.onLowMemory(); BitmapAjaxCallback.clearCache(); } } diff --git a/docs-android/app/src/main/java/com/sismics/docs/activity/DocumentViewActivity.java b/docs-android/app/src/main/java/com/sismics/docs/activity/DocumentViewActivity.java index ea2e8cbe..d6bc8f12 100644 --- a/docs-android/app/src/main/java/com/sismics/docs/activity/DocumentViewActivity.java +++ b/docs-android/app/src/main/java/com/sismics/docs/activity/DocumentViewActivity.java @@ -19,11 +19,15 @@ import android.support.v7.app.AppCompatActivity; import android.support.v7.widget.Toolbar; import android.text.TextUtils; import android.text.format.DateFormat; +import android.view.ContextMenu; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; +import android.widget.AdapterView; import android.widget.Button; +import android.widget.EditText; +import android.widget.ImageButton; import android.widget.ImageView; import android.widget.ListView; import android.widget.TextView; @@ -31,7 +35,10 @@ import android.widget.Toast; import com.sismics.docs.R; import com.sismics.docs.adapter.AclListAdapter; +import com.sismics.docs.adapter.CommentListAdapter; import com.sismics.docs.adapter.FilePagerAdapter; +import com.sismics.docs.event.CommentAddEvent; +import com.sismics.docs.event.CommentDeleteEvent; import com.sismics.docs.event.DocumentDeleteEvent; import com.sismics.docs.event.DocumentEditEvent; import com.sismics.docs.event.DocumentFullscreenEvent; @@ -40,6 +47,7 @@ import com.sismics.docs.event.FileDeleteEvent; import com.sismics.docs.fragment.DocShareFragment; import com.sismics.docs.listener.JsonHttpResponseHandler; import com.sismics.docs.model.application.ApplicationContext; +import com.sismics.docs.resource.CommentResource; import com.sismics.docs.resource.DocumentResource; import com.sismics.docs.resource.FileResource; import com.sismics.docs.service.FileUploadService; @@ -83,6 +91,11 @@ public class DocumentViewActivity extends AppCompatActivity { */ private FilePagerAdapter filePagerAdapter; + /** + * Comment list adapter. + */ + private CommentListAdapter commentListAdapter; + /** * Document displayed. */ @@ -241,6 +254,39 @@ public class DocumentViewActivity extends AppCompatActivity { } }); + // Button add a comment + ImageButton imageButton = (ImageButton) findViewById(R.id.addCommentBtn); + imageButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + final EditText commentEditText = (EditText) findViewById(R.id.commentEditText); + if (commentEditText.getText().length() == 0) { + // No content for the new comment + return; + } + + Toast.makeText(DocumentViewActivity.this, R.string.adding_comment, Toast.LENGTH_LONG).show(); + + CommentResource.add(DocumentViewActivity.this, + DocumentViewActivity.this.document.optString("id"), + commentEditText.getText().toString(), + new JsonHttpResponseHandler() { + public void onSuccess(int statusCode, Header[] headers, JSONObject response) { + EventBus.getDefault().post(new CommentAddEvent(response)); + commentEditText.setText(""); + } + + @Override + public void onAllFailure(int statusCode, Header[] headers, byte[] responseBytes, Throwable throwable) { + Toast.makeText(DocumentViewActivity.this, R.string.comment_add_failure, Toast.LENGTH_LONG).show(); + } + }); + } + }); + + // Grab the comments + updateComments(); + // Grab the attached files updateFiles(); @@ -268,6 +314,15 @@ public class DocumentViewActivity extends AppCompatActivity { } return true; + case R.id.comments: + drawerLayout = (DrawerLayout) findViewById(R.id.drawer_layout); + if (drawerLayout.isDrawerVisible(GravityCompat.START)) { + drawerLayout.closeDrawer(GravityCompat.START); + } else { + drawerLayout.openDrawer(GravityCompat.START); + } + return true; + case R.id.download_file: downloadCurrentFile(); return true; @@ -507,6 +562,36 @@ public class DocumentViewActivity extends AppCompatActivity { } } + /** + * A comment add event has been fired. + * + * @param event Comment add event + */ + public void onEventMainThread(CommentAddEvent event) { + if (commentListAdapter == null) return; + TextView emptyView = (TextView) findViewById(R.id.commentEmptyView); + ListView listView = (ListView) findViewById(R.id.commentListView); + emptyView.setVisibility(View.GONE); + listView.setVisibility(View.VISIBLE); + commentListAdapter.add(event.getComment()); + } + + /** + * A comment delete event has been fired. + * + * @param event Comment add event + */ + public void onEventMainThread(CommentDeleteEvent event) { + if (commentListAdapter == null) return; + TextView emptyView = (TextView) findViewById(R.id.commentEmptyView); + ListView listView = (ListView) findViewById(R.id.commentListView); + commentListAdapter.remove(event.getCommentId()); + if (commentListAdapter.getCount() == 0) { + emptyView.setVisibility(View.VISIBLE); + listView.setVisibility(View.GONE); + } + } + @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { if (document == null) return; @@ -572,6 +657,89 @@ public class DocumentViewActivity extends AppCompatActivity { }); } + @Override + public void onCreateContextMenu(ContextMenu menu, View view, ContextMenu.ContextMenuInfo menuInfo) { + switch (view.getId()) { + case R.id.commentListView: + if (commentListAdapter == null || document == null) return; + AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuInfo; + JSONObject comment = commentListAdapter.getItem(info.position); + boolean writable = document.optBoolean("writable"); + String creator = comment.optString("creator"); + String username = ApplicationContext.getInstance().getUserInfo().optString("username"); + if (writable || creator.equals(username)) { + menu.add(Menu.NONE, 0, 0, getString(R.string.comment_delete)); + } + } + } + + @Override + public boolean onContextItemSelected(MenuItem item) { + // Use real ids if more than one item someday + if (item.getItemId() == 0) { + // Delete a comment + AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo)item.getMenuInfo(); + if (commentListAdapter == null) return false; + JSONObject comment = commentListAdapter.getItem(info.position); + final String commentId = comment.optString("id"); + Toast.makeText(DocumentViewActivity.this, R.string.deleting_comment, Toast.LENGTH_LONG).show(); + + CommentResource.remove(DocumentViewActivity.this, commentId, new JsonHttpResponseHandler() { + @Override + public void onSuccess(int statusCode, Header[] headers, JSONObject response) { + EventBus.getDefault().post(new CommentDeleteEvent(commentId)); + } + + @Override + public void onAllFailure(int statusCode, Header[] headers, byte[] responseBytes, Throwable throwable) { + Toast.makeText(DocumentViewActivity.this, R.string.error_deleting_comment, Toast.LENGTH_LONG).show(); + } + }); + + return true; + } + + return false; + } + + /** + * Refresh comments list. + */ + private void updateComments() { + if (document == null) return; + + final View progressBar = findViewById(R.id.commentProgressView); + final TextView emptyView = (TextView) findViewById(R.id.commentEmptyView); + final ListView listView = (ListView) findViewById(R.id.commentListView); + progressBar.setVisibility(View.VISIBLE); + emptyView.setVisibility(View.GONE); + listView.setVisibility(View.GONE); + registerForContextMenu(listView); + + CommentResource.list(this, document.optString("id"), new JsonHttpResponseHandler() { + @Override + public void onSuccess(int statusCode, Header[] headers, JSONObject response) { + JSONArray comments = response.optJSONArray("comments"); + commentListAdapter = new CommentListAdapter(comments); + listView.setAdapter(commentListAdapter); + listView.setVisibility(View.VISIBLE); + progressBar.setVisibility(View.GONE); + if (comments.length() == 0) { + listView.setVisibility(View.GONE); + emptyView.setVisibility(View.VISIBLE); + } + } + + @Override + public void onAllFailure(int statusCode, Header[] headers, byte[] responseBytes, Throwable throwable) { + emptyView.setText(R.string.error_loading_comments); + progressBar.setVisibility(View.GONE); + listView.setVisibility(View.GONE); + emptyView.setVisibility(View.VISIBLE); + } + }); + } + /** * Refresh files list. */ diff --git a/docs-android/app/src/main/java/com/sismics/docs/adapter/CommentListAdapter.java b/docs-android/app/src/main/java/com/sismics/docs/adapter/CommentListAdapter.java new file mode 100644 index 00000000..3a11585c --- /dev/null +++ b/docs-android/app/src/main/java/com/sismics/docs/adapter/CommentListAdapter.java @@ -0,0 +1,127 @@ +package com.sismics.docs.adapter; + +import android.content.Context; +import android.graphics.Bitmap; +import android.text.format.DateFormat; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.ImageView; +import android.widget.TextView; + +import com.androidquery.AQuery; +import com.androidquery.callback.BitmapAjaxCallback; +import com.sismics.docs.R; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +/** + * Comment list adapter. + * + * @author bgamard. + */ +public class CommentListAdapter extends BaseAdapter { + /** + * AQuery. + */ + private AQuery aq; + + /** + * Tags. + */ + private List commentList = new ArrayList<>(); + + /** + * Comment list adapter. + * + * @param commentsArray Comments + */ + public CommentListAdapter(JSONArray commentsArray) { + for (int i = 0; i < commentsArray.length(); i++) { + commentList.add(commentsArray.optJSONObject(i)); + } + } + + @Override + public int getCount() { + return commentList.size(); + } + + @Override + public JSONObject getItem(int position) { + return commentList.get(position); + } + + @Override + public long getItemId(int position) { + return getItem(position).optString("id").hashCode(); + } + + @Override + public View getView(int position, View view, ViewGroup parent) { + if (view == null) { + LayoutInflater vi = (LayoutInflater) parent.getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE); + view = vi.inflate(R.layout.comment_list_item, parent, false); + } + + if (aq == null) { + aq = new AQuery(view); + } else { + aq.recycle(view); + } + + // Fill the view + JSONObject comment = getItem(position); + TextView creatorTextView = (TextView) view.findViewById(R.id.creatorTextView); + TextView dateTextView = (TextView) view.findViewById(R.id.dateTextView); + TextView contentTextView = (TextView) view.findViewById(R.id.contentTextView); + ImageView gravatarImageView = (ImageView) view.findViewById(R.id.gravatarImageView); + creatorTextView.setText(comment.optString("creator")); + dateTextView.setText(DateFormat.getDateFormat(dateTextView.getContext()).format(new Date(comment.optLong("create_date")))); + contentTextView.setText(comment.optString("content")); + + // Gravatar image + String gravatarUrl = "http://www.gravatar.com/avatar/" + comment.optString("creator_gravatar") + "?s=128d=identicon"; + if (aq.shouldDelay(position, view, parent, gravatarUrl)) { + aq.id(gravatarImageView).image((Bitmap) null); + } else { + aq.id(gravatarImageView).image(new BitmapAjaxCallback() + .url(gravatarUrl) + .animation(AQuery.FADE_IN_NETWORK) + ); + } + + return view; + } + + /** + * Add a new comment. + * + * @param comment Comment + */ + public void add(JSONObject comment) { + commentList.add(comment); + notifyDataSetChanged(); + } + + /** + * Remove a comment. + * + * @param commentId Comment ID + */ + public void remove(String commentId) { + for (JSONObject comment : commentList) { + if (comment.optString("id").equals(commentId)) { + commentList.remove(comment); + notifyDataSetChanged(); + return; + } + } + } +} diff --git a/docs-android/app/src/main/java/com/sismics/docs/event/CommentAddEvent.java b/docs-android/app/src/main/java/com/sismics/docs/event/CommentAddEvent.java new file mode 100644 index 00000000..374fda6d --- /dev/null +++ b/docs-android/app/src/main/java/com/sismics/docs/event/CommentAddEvent.java @@ -0,0 +1,33 @@ +package com.sismics.docs.event; + +import org.json.JSONObject; + +/** + * Comment add event. + * + * @author bgamard. + */ +public class CommentAddEvent { + /** + * Comment. + */ + private JSONObject comment; + + /** + * Create a comment add event. + * + * @param comment Comment + */ + public CommentAddEvent(JSONObject comment) { + this.comment = comment; + } + + /** + * Getter of comment. + * + * @return comment + */ + public JSONObject getComment() { + return comment; + } +} diff --git a/docs-android/app/src/main/java/com/sismics/docs/event/CommentDeleteEvent.java b/docs-android/app/src/main/java/com/sismics/docs/event/CommentDeleteEvent.java new file mode 100644 index 00000000..4d25863d --- /dev/null +++ b/docs-android/app/src/main/java/com/sismics/docs/event/CommentDeleteEvent.java @@ -0,0 +1,31 @@ +package com.sismics.docs.event; + +/** + * Comment delete event. + * + * @author bgamard. + */ +public class CommentDeleteEvent { + /** + * Comment ID. + */ + private String commentId; + + /** + * Create a comment add event. + * + * @param commentId Comment ID + */ + public CommentDeleteEvent(String commentId) { + this.commentId = commentId; + } + + /** + * Getter of commentId. + * + * @return commentId + */ + public String getCommentId() { + return commentId; + } +} diff --git a/docs-android/app/src/main/java/com/sismics/docs/resource/CommentResource.java b/docs-android/app/src/main/java/com/sismics/docs/resource/CommentResource.java new file mode 100644 index 00000000..6e545956 --- /dev/null +++ b/docs-android/app/src/main/java/com/sismics/docs/resource/CommentResource.java @@ -0,0 +1,66 @@ +package com.sismics.docs.resource; + +import android.content.Context; + +import com.loopj.android.http.RequestParams; +import com.sismics.docs.listener.JsonHttpResponseHandler; + + +/** + * Access to /comment API. + * + * @author bgamard + */ +public class CommentResource extends BaseResource { + /** + * GET /comment/id. + * + * @param context Context + * @param documentId Document ID + * @param responseHandler Callback + */ + public static void list(Context context, String documentId, JsonHttpResponseHandler responseHandler) { + init(context); + + client.get(getApiUrl(context) + "/comment/" + documentId, responseHandler); + } + + /** + * PUT /comment. + * + * @param context Context + * @param documentId Document ID + * @param content Comment content + * @param responseHandler Callback + */ + public static void add(Context context, String documentId, String content, JsonHttpResponseHandler responseHandler) { + init(context); + + RequestParams params = new RequestParams(); + params.put("id", documentId); + params.put("content", content); + client.put(getApiUrl(context) + "/comment", params, responseHandler); + } + + /** + * DELETE /comment/id. + * + * @param context Context + * @param commentId Comment ID + * @param responseHandler Callback + */ + public static void remove(Context context, String commentId, JsonHttpResponseHandler responseHandler) { + init(context); + + client.delete(getApiUrl(context) + "/comment/" + commentId, responseHandler); + } + + /** + * Cancel pending requests. + * + * @param context Context + */ + public static void cancel(Context context) { + client.cancelRequests(context, true); + } +} diff --git a/docs-android/app/src/main/res/drawable-xhdpi/ic_comment_black_24dp.png b/docs-android/app/src/main/res/drawable-xhdpi/ic_comment_black_24dp.png new file mode 100644 index 00000000..412de9b0 Binary files /dev/null and b/docs-android/app/src/main/res/drawable-xhdpi/ic_comment_black_24dp.png differ diff --git a/docs-android/app/src/main/res/drawable-xhdpi/ic_send_grey600_24dp.png b/docs-android/app/src/main/res/drawable-xhdpi/ic_send_grey600_24dp.png new file mode 100644 index 00000000..e1380462 Binary files /dev/null and b/docs-android/app/src/main/res/drawable-xhdpi/ic_send_grey600_24dp.png differ diff --git a/docs-android/app/src/main/res/drawable-xxhdpi/ic_comment_black_24dp.png b/docs-android/app/src/main/res/drawable-xxhdpi/ic_comment_black_24dp.png new file mode 100644 index 00000000..382ee7a1 Binary files /dev/null and b/docs-android/app/src/main/res/drawable-xxhdpi/ic_comment_black_24dp.png differ diff --git a/docs-android/app/src/main/res/drawable-xxhdpi/ic_send_grey600_24dp.png b/docs-android/app/src/main/res/drawable-xxhdpi/ic_send_grey600_24dp.png new file mode 100644 index 00000000..2c7a8026 Binary files /dev/null and b/docs-android/app/src/main/res/drawable-xxhdpi/ic_send_grey600_24dp.png differ diff --git a/docs-android/app/src/main/res/layout/comment_list_item.xml b/docs-android/app/src/main/res/layout/comment_list_item.xml new file mode 100644 index 00000000..eb444391 --- /dev/null +++ b/docs-android/app/src/main/res/layout/comment_list_item.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs-android/app/src/main/res/layout/document_view_activity.xml b/docs-android/app/src/main/res/layout/document_view_activity.xml index 19744f59..a0609e89 100644 --- a/docs-android/app/src/main/res/layout/document_view_activity.xml +++ b/docs-android/app/src/main/res/layout/document_view_activity.xml @@ -37,6 +37,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + All languages Toggle informations Who can access + Comments + No comments + Error loading comments + Send + Add a comment + Error adding a comment + Adding a comment + Delete comment + Deleting comment + Error deleting comment diff --git a/docs-android/gradle/wrapper/gradle-wrapper.properties b/docs-android/gradle/wrapper/gradle-wrapper.properties index 695bbdaa..476556cf 100644 --- a/docs-android/gradle/wrapper/gradle-wrapper.properties +++ b/docs-android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Wed Nov 26 21:58:48 CET 2014 +#Mon Nov 23 20:12:30 CET 2015 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-2.2.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-2.8-all.zip diff --git a/docs-core/src/dev/resources/db/update/dbupdate-000-1.sql b/docs-core/src/dev/resources/db/update/dbupdate-000-1.sql index e69de29b..afec483c 100644 --- a/docs-core/src/dev/resources/db/update/dbupdate-000-1.sql +++ b/docs-core/src/dev/resources/db/update/dbupdate-000-1.sql @@ -0,0 +1 @@ +update T_CONFIG set CFG_VALUE_C = 'RAM' where CFG_ID_C = 'LUCENE_DIRECTORY_STORAGE'; \ No newline at end of file diff --git a/docs-core/src/main/java/com/sismics/docs/core/dao/jpa/UserDao.java b/docs-core/src/main/java/com/sismics/docs/core/dao/jpa/UserDao.java index 3a6c0154..a0d20c1c 100644 --- a/docs-core/src/main/java/com/sismics/docs/core/dao/jpa/UserDao.java +++ b/docs-core/src/main/java/com/sismics/docs/core/dao/jpa/UserDao.java @@ -101,6 +101,8 @@ public class UserDao { // Update the user userFromDb.setEmail(user.getEmail()); + userFromDb.setStorageQuota(user.getStorageQuota()); + userFromDb.setStorageCurrent(user.getStorageCurrent()); // Create audit log AuditLogUtil.create(userFromDb, AuditLogType.UPDATE); @@ -226,7 +228,7 @@ public class UserDao { Map parameterMap = new HashMap(); List criteriaList = new ArrayList(); - StringBuilder sb = new StringBuilder("select u.USE_ID_C as c0, u.USE_USERNAME_C as c1, u.USE_EMAIL_C as c2, u.USE_CREATEDATE_D as c3"); + StringBuilder sb = new StringBuilder("select u.USE_ID_C as c0, u.USE_USERNAME_C as c1, u.USE_EMAIL_C as c2, u.USE_CREATEDATE_D as c3, u.USE_STORAGECURRENT_N as c4, u.USE_STORAGEQUOTA_N as c5"); sb.append(" from T_USER u "); // Add search criterias @@ -255,6 +257,8 @@ public class UserDao { userDto.setUsername((String) o[i++]); userDto.setEmail((String) o[i++]); userDto.setCreateTimestamp(((Timestamp) o[i++]).getTime()); + userDto.setStorageCurrent(((Number) o[i++]).longValue()); + userDto.setStorageQuota(((Number) o[i++]).longValue()); userDtoList.add(userDto); } paginatedList.setResultList(userDtoList); diff --git a/docs-core/src/main/java/com/sismics/docs/core/dao/jpa/dto/UserDto.java b/docs-core/src/main/java/com/sismics/docs/core/dao/jpa/dto/UserDto.java index 956d0c90..2dda296e 100644 --- a/docs-core/src/main/java/com/sismics/docs/core/dao/jpa/dto/UserDto.java +++ b/docs-core/src/main/java/com/sismics/docs/core/dao/jpa/dto/UserDto.java @@ -1,5 +1,6 @@ package com.sismics.docs.core.dao.jpa.dto; + /** * User DTO. * @@ -26,6 +27,16 @@ public class UserDto { */ private Long createTimestamp; + /** + * Storage quota. + */ + private Long storageQuota; + + /** + * Storage current usage. + */ + private Long storageCurrent; + /** * Getter of id. * @@ -88,6 +99,22 @@ public class UserDto { public Long getCreateTimestamp() { return createTimestamp; } + + public Long getStorageQuota() { + return storageQuota; + } + + public void setStorageQuota(Long storageQuota) { + this.storageQuota = storageQuota; + } + + public Long getStorageCurrent() { + return storageCurrent; + } + + public void setStorageCurrent(Long storageCurrent) { + this.storageCurrent = storageCurrent; + } /** * Setter of createTimestamp. diff --git a/docs-core/src/main/java/com/sismics/docs/core/model/jpa/User.java b/docs-core/src/main/java/com/sismics/docs/core/model/jpa/User.java index eb51877b..5476a4ab 100644 --- a/docs-core/src/main/java/com/sismics/docs/core/model/jpa/User.java +++ b/docs-core/src/main/java/com/sismics/docs/core/model/jpa/User.java @@ -54,6 +54,18 @@ public class User implements Loggable { @Column(name = "USE_EMAIL_C", nullable = false, length = 100) private String email; + /** + * Storage quota. + */ + @Column(name = "USE_STORAGEQUOTA_N", nullable = false) + private Long storageQuota; + + /** + * Storage current usage. + */ + @Column(name = "USE_STORAGECURRENT_N", nullable = false) + private Long storageCurrent; + /** * Creation date. */ @@ -66,149 +78,87 @@ public class User implements Loggable { @Column(name = "USE_DELETEDATE_D") private Date deleteDate; - /** - * Getter of id. - * - * @return id - */ public String getId() { return id; } - /** - * Setter of id. - * - * @param id id - */ public void setId(String id) { this.id = id; } - /** - * Getter of roleId. - * - * @return roleId - */ public String getRoleId() { return roleId; } - /** - * Setter of roleId. - * - * @param roleId roleId - */ public void setRoleId(String roleId) { this.roleId = roleId; } - /** - * Getter of username. - * - * @return username - */ public String getUsername() { return username; } - /** - * Setter of username. - * - * @param username username - */ public void setUsername(String username) { this.username = username; } - /** - * Getter of password. - * - * @return password - */ public String getPassword() { return password; } - /** - * Setter of password. - * - * @param password password - */ public void setPassword(String password) { this.password = password; } - /** - * Getter of email. - * - * @return email - */ public String getEmail() { return email; } - /** - * Setter of email. - * - * @param email email - */ public void setEmail(String email) { this.email = email; } - /** - * Getter of createDate. - * - * @return createDate - */ public Date getCreateDate() { return createDate; } - /** - * Setter of createDate. - * - * @param createDate createDate - */ public void setCreateDate(Date createDate) { this.createDate = createDate; } - /** - * Getter of deleteDate. - * - * @return deleteDate - */ @Override public Date getDeleteDate() { return deleteDate; } - /** - * Setter of deleteDate. - * - * @param deleteDate deleteDate - */ public void setDeleteDate(Date deleteDate) { this.deleteDate = deleteDate; } - /** - * Getter de privateKey. - * @return privateKey - */ public String getPrivateKey() { return privateKey; } - /** - * Setter de privateKey. - * @param privateKey privateKey - */ public void setPrivateKey(String privateKey) { this.privateKey = privateKey; } + public Long getStorageQuota() { + return storageQuota; + } + + public void setStorageQuota(Long storageQuota) { + this.storageQuota = storageQuota; + } + + public Long getStorageCurrent() { + return storageCurrent; + } + + public void setStorageCurrent(Long storageCurrent) { + this.storageCurrent = storageCurrent; + } + @Override public String toString() { return MoreObjects.toStringHelper(this) diff --git a/docs-core/src/main/java/com/sismics/docs/core/service/IndexingService.java b/docs-core/src/main/java/com/sismics/docs/core/service/IndexingService.java index d5988d3c..3efd055b 100644 --- a/docs-core/src/main/java/com/sismics/docs/core/service/IndexingService.java +++ b/docs-core/src/main/java/com/sismics/docs/core/service/IndexingService.java @@ -1,7 +1,7 @@ package com.sismics.docs.core.service; -import java.io.File; import java.io.IOException; +import java.nio.file.Path; import java.util.concurrent.TimeUnit; import org.apache.lucene.index.DirectoryReader; @@ -56,10 +56,10 @@ public class IndexingService extends AbstractScheduledService { directory = new RAMDirectory(); log.info("Using RAM Lucene storage"); } else if (luceneStorageConfig.equals(Constants.LUCENE_DIRECTORY_STORAGE_FILE)) { - File luceneDirectory = DirectoryUtil.getLuceneDirectory(); + Path luceneDirectory = DirectoryUtil.getLuceneDirectory(); log.info("Using file Lucene storage: {}", luceneDirectory); try { - directory = new SimpleFSDirectory(luceneDirectory, new SimpleFSLockFactory()); + directory = new SimpleFSDirectory(luceneDirectory.toFile(), new SimpleFSLockFactory()); } catch (IOException e) { log.error("Error initializing Lucene index", e); } diff --git a/docs-core/src/main/java/com/sismics/docs/core/util/DirectoryUtil.java b/docs-core/src/main/java/com/sismics/docs/core/util/DirectoryUtil.java index dfbab290..7f947157 100644 --- a/docs-core/src/main/java/com/sismics/docs/core/util/DirectoryUtil.java +++ b/docs-core/src/main/java/com/sismics/docs/core/util/DirectoryUtil.java @@ -1,6 +1,9 @@ package com.sismics.docs.core.util; -import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import org.apache.commons.lang.StringUtils; @@ -17,27 +20,31 @@ public class DirectoryUtil { * * @return Base data directory */ - public static File getBaseDataDirectory() { - File baseDataDir = null; + public static Path getBaseDataDirectory() { + Path baseDataDir = null; if (StringUtils.isNotBlank(EnvironmentUtil.getDocsHome())) { // If the docs.home property is set then use it - baseDataDir = new File(EnvironmentUtil.getDocsHome()); + baseDataDir = Paths.get(EnvironmentUtil.getDocsHome()); } else if (EnvironmentUtil.isUnitTest()) { // For unit testing, use a temporary directory - baseDataDir = new File(System.getProperty("java.io.tmpdir")); + baseDataDir = Paths.get(System.getProperty("java.io.tmpdir")); } else { // We are in a webapp environment and nothing is specified, use the default directory for this OS if (EnvironmentUtil.isUnix()) { - baseDataDir = new File("/var/docs"); + baseDataDir = Paths.get("/var/docs"); } if (EnvironmentUtil.isWindows()) { - baseDataDir = new File(EnvironmentUtil.getWindowsAppData() + "\\Sismics\\Docs"); + baseDataDir = Paths.get(EnvironmentUtil.getWindowsAppData() + "\\Sismics\\Docs"); } else if (EnvironmentUtil.isMacOs()) { - baseDataDir = new File(EnvironmentUtil.getMacOsUserHome() + "/Library/Sismics/Docs"); + baseDataDir = Paths.get(EnvironmentUtil.getMacOsUserHome() + "/Library/Sismics/Docs"); } } - if (baseDataDir != null && !baseDataDir.isDirectory()) { - baseDataDir.mkdirs(); + if (baseDataDir != null && !Files.isDirectory(baseDataDir)) { + try { + Files.createDirectories(baseDataDir); + } catch (IOException e) { + throw new RuntimeException(e); + } } return baseDataDir; @@ -48,7 +55,7 @@ public class DirectoryUtil { * * @return Database directory. */ - public static File getDbDirectory() { + public static Path getDbDirectory() { return getDataSubDirectory("db"); } @@ -57,7 +64,7 @@ public class DirectoryUtil { * * @return Lucene indexes directory. */ - public static File getLuceneDirectory() { + public static Path getLuceneDirectory() { return getDataSubDirectory("lucene"); } @@ -66,7 +73,7 @@ public class DirectoryUtil { * * @return Storage directory. */ - public static File getStorageDirectory() { + public static Path getStorageDirectory() { return getDataSubDirectory("storage"); } @@ -75,7 +82,7 @@ public class DirectoryUtil { * * @return Log directory. */ - public static File getLogDirectory() { + public static Path getLogDirectory() { return getDataSubDirectory("log"); } @@ -84,11 +91,15 @@ public class DirectoryUtil { * * @return Subdirectory */ - private static File getDataSubDirectory(String subdirectory) { - File baseDataDir = getBaseDataDirectory(); - File directory = new File(baseDataDir.getPath() + File.separator + subdirectory); - if (!directory.isDirectory()) { - directory.mkdirs(); + private static Path getDataSubDirectory(String subdirectory) { + Path baseDataDir = getBaseDataDirectory(); + Path directory = baseDataDir.resolve(subdirectory); + if (!Files.isDirectory(directory)) { + try { + Files.createDirectories(directory); + } catch (IOException e) { + throw new RuntimeException(e); + } } return directory; } diff --git a/docs-core/src/main/java/com/sismics/docs/core/util/FileUtil.java b/docs-core/src/main/java/com/sismics/docs/core/util/FileUtil.java index 40e04e15..d836e94b 100644 --- a/docs-core/src/main/java/com/sismics/docs/core/util/FileUtil.java +++ b/docs-core/src/main/java/com/sismics/docs/core/util/FileUtil.java @@ -1,13 +1,11 @@ package com.sismics.docs.core.util; import java.awt.image.BufferedImage; -import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import javax.crypto.Cipher; import javax.crypto.CipherInputStream; @@ -132,7 +130,7 @@ public class FileUtil { */ public static void save(InputStream inputStream, File file, String privateKey) throws Exception { Cipher cipher = EncryptionUtil.getEncryptionCipher(privateKey); - Path path = Paths.get(DirectoryUtil.getStorageDirectory().getPath(), file.getId()); + Path path = DirectoryUtil.getStorageDirectory().resolve(file.getId()); Files.copy(new CipherInputStream(inputStream, cipher), path); // Generate file variations @@ -172,21 +170,15 @@ public class FileUtil { image.flush(); // Write "web" encrypted image - java.io.File outputFile = Paths.get(DirectoryUtil.getStorageDirectory().getPath(), file.getId() + "_web").toFile(); - OutputStream outputStream = new CipherOutputStream(new FileOutputStream(outputFile), cipher); - try { + Path outputFile = DirectoryUtil.getStorageDirectory().resolve(file.getId() + "_web"); + try (OutputStream outputStream = new CipherOutputStream(Files.newOutputStream(outputFile), cipher)) { ImageUtil.writeJpeg(web, outputStream); - } finally { - outputStream.close(); } // Write "thumb" encrypted image - outputFile = Paths.get(DirectoryUtil.getStorageDirectory().getPath(), file.getId() + "_thumb").toFile(); - outputStream = new CipherOutputStream(new FileOutputStream(outputFile), cipher); - try { + outputFile = DirectoryUtil.getStorageDirectory().resolve(file.getId() + "_thumb"); + try (OutputStream outputStream = new CipherOutputStream(Files.newOutputStream(outputFile), cipher)) { ImageUtil.writeJpeg(thumbnail, outputStream); - } finally { - outputStream.close(); } } } @@ -195,20 +187,21 @@ public class FileUtil { * Remove a file from the storage filesystem. * * @param file File to delete + * @throws IOException */ - public static void delete(File file) { - java.io.File storedFile = Paths.get(DirectoryUtil.getStorageDirectory().getPath(), file.getId()).toFile(); - java.io.File webFile = Paths.get(DirectoryUtil.getStorageDirectory().getPath(), file.getId() + "_web").toFile(); - java.io.File thumbnailFile = Paths.get(DirectoryUtil.getStorageDirectory().getPath(), file.getId() + "_thumb").toFile(); + public static void delete(File file) throws IOException { + Path storedFile = DirectoryUtil.getStorageDirectory().resolve(file.getId()); + Path webFile = DirectoryUtil.getStorageDirectory().resolve(file.getId() + "_web"); + Path thumbnailFile = DirectoryUtil.getStorageDirectory().resolve(file.getId() + "_thumb"); - if (storedFile.exists()) { - storedFile.delete(); + if (Files.exists(storedFile)) { + Files.delete(storedFile); } - if (webFile.exists()) { - webFile.delete(); + if (Files.exists(webFile)) { + Files.delete(webFile); } - if (thumbnailFile.exists()) { - thumbnailFile.delete(); + if (Files.exists(thumbnailFile)) { + Files.delete(thumbnailFile); } } } diff --git a/docs-core/src/main/java/com/sismics/docs/core/util/math/MathUtil.java b/docs-core/src/main/java/com/sismics/docs/core/util/math/MathUtil.java deleted file mode 100644 index 435edc19..00000000 --- a/docs-core/src/main/java/com/sismics/docs/core/util/math/MathUtil.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.sismics.docs.core.util.math; - - -/** - * Classe utilitaire pour les calculs - * - * @author bgamard - * - */ -public class MathUtil { - - /** - * Arrondi à 2 décimales près - * - * @param d Nombre à arrondir - * @return Nombre arrondi - */ - public static Double round(Double d) { - return Math.round(d * 100.0) / 100.0; - } - - /** - * Contraint une valeur entre min et max. - * - * @param value Valeur - * @param min Minimum - * @param max Maximum - * @return Valeur contrainte - */ - public static double clip(double value, double min, double max) { - if (value < min) { - return min; - } - if (value > max) { - return max; - } - return value; - } - - /** - * Interpole une valeur entre deux points. - * - * @param x Valeur à interpoler - * @param x1 Point 1 (x) - * @param y1 Point 1 (y) - * @param x2 Point 2 (x) - * @param y2 Point 2 (y) - * @return Valeur interpolée - */ - public static double interpolate(double x, double x1, double y1, double x2, double y2) { - double alpha = (x - x1) / (x2 - x1); - - return y1 * (1 - alpha) + y2 * alpha; - } - - /** - * Retourne un Double depuis un Number. - * - * @param number Number - * @return Double - */ - public static Double getDoubleFromNumber(Number number) { - if (number == null) { - return null; - } - - return number.doubleValue(); - } - - /** - * Retourne un Integer depuis un Number. - * - * @param number Number - * @return Integer - */ - public static Integer getIntegerFromNumber(Number number) { - if (number == null) { - return null; - } - - return number.intValue(); - } - - /** - * Retourne un Long depuis un Number. - * - * @param number Number - * @return Long - */ - public static Long getLongFromNumber(Number number) { - if (number == null) { - return null; - } - - return number.longValue(); - } -} diff --git a/docs-core/src/main/java/com/sismics/util/jpa/EMF.java b/docs-core/src/main/java/com/sismics/util/jpa/EMF.java index 7dbc8746..01287b9e 100644 --- a/docs-core/src/main/java/com/sismics/util/jpa/EMF.java +++ b/docs-core/src/main/java/com/sismics/util/jpa/EMF.java @@ -1,6 +1,16 @@ package com.sismics.util.jpa; -import com.sismics.docs.core.util.DirectoryUtil; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; + +import javax.persistence.EntityManagerFactory; +import javax.persistence.Persistence; + import org.hibernate.cfg.Environment; import org.hibernate.internal.util.config.ConfigurationHelper; import org.hibernate.service.ServiceRegistry; @@ -8,15 +18,7 @@ import org.hibernate.service.ServiceRegistryBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import javax.persistence.EntityManagerFactory; -import javax.persistence.Persistence; -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.net.URL; -import java.util.HashMap; -import java.util.Map; -import java.util.Properties; +import com.sismics.docs.core.util.DirectoryUtil; /** * Entity manager factory. @@ -79,8 +81,8 @@ public final class EMF { log.info("Configuring EntityManager from environment parameters"); Map props = new HashMap(); props.put("hibernate.connection.driver_class", "org.h2.Driver"); - File dbDirectory = DirectoryUtil.getDbDirectory(); - String dbFile = dbDirectory.getAbsoluteFile() + File.separator + "docs"; + Path dbDirectory = DirectoryUtil.getDbDirectory(); + String dbFile = dbDirectory.resolve("docs").toAbsolutePath().toString(); props.put("hibernate.connection.url", "jdbc:h2:file:" + dbFile + ";CACHE_SIZE=65536"); props.put("hibernate.connection.username", "sa"); props.put("hibernate.hbm2ddl.auto", "none"); diff --git a/docs-core/src/main/resources/config.properties b/docs-core/src/main/resources/config.properties index 53e95e79..03d382c6 100644 --- a/docs-core/src/main/resources/config.properties +++ b/docs-core/src/main/resources/config.properties @@ -1 +1 @@ -db.version=3 \ No newline at end of file +db.version=4 \ No newline at end of file diff --git a/docs-core/src/main/resources/db/update/dbupdate-004-0.sql b/docs-core/src/main/resources/db/update/dbupdate-004-0.sql new file mode 100644 index 00000000..7e2406ba --- /dev/null +++ b/docs-core/src/main/resources/db/update/dbupdate-004-0.sql @@ -0,0 +1,3 @@ +alter table T_USER add column USE_STORAGEQUOTA_N bigint not null default 10000000000; +alter table T_USER add column USE_STORAGECURRENT_N bigint not null default 0; +update T_CONFIG set CFG_VALUE_C = '4' where CFG_ID_C = 'DB_VERSION'; diff --git a/docs-core/src/test/java/com/sismics/docs/core/dao/jpa/TestJpa.java b/docs-core/src/test/java/com/sismics/docs/core/dao/jpa/TestJpa.java index 8998cb66..0dc8d675 100644 --- a/docs-core/src/test/java/com/sismics/docs/core/dao/jpa/TestJpa.java +++ b/docs-core/src/test/java/com/sismics/docs/core/dao/jpa/TestJpa.java @@ -20,6 +20,8 @@ public class TestJpa extends BaseTransactionalTest { user.setUsername("username"); user.setEmail("toto@docs.com"); user.setRoleId("admin"); + user.setStorageCurrent(0l); + user.setStorageQuota(10l); user.setPrivateKey("AwesomePrivateKey"); String id = userDao.create(user); diff --git a/docs-parent/pom.xml b/docs-parent/pom.xml index 8015a475..18d616e0 100644 --- a/docs-parent/pom.xml +++ b/docs-parent/pom.xml @@ -48,6 +48,7 @@ 2.5.2 2.7 2.6 + 2.18.1 9.2.13.v20150730 @@ -108,12 +109,17 @@ ${org.apache.maven.plugins.maven-war-plugin.version} + + org.apache.maven.plugins + maven-surefire-plugin + ${org.apache.maven.plugins.maven-surefire-plugin.version} + + org.eclipse.jetty jetty-maven-plugin ${org.eclipse.jetty.jetty-maven-plugin.version} - diff --git a/docs-web-common/src/main/java/com/sismics/rest/util/ValidationUtil.java b/docs-web-common/src/main/java/com/sismics/rest/util/ValidationUtil.java index 8bcea9ff..1d146641 100644 --- a/docs-web-common/src/main/java/com/sismics/rest/util/ValidationUtil.java +++ b/docs-web-common/src/main/java/com/sismics/rest/util/ValidationUtil.java @@ -155,7 +155,23 @@ public class ValidationUtil { try { return Integer.valueOf(s); } catch (NumberFormatException e) { - throw new ClientException("Validation Error", MessageFormat.format("{0} is not a number", name)); + throw new ClientException("ValidationError", MessageFormat.format("{0} is not a number", name)); + } + } + + /** + * Checks if the string is a number. + * + * @param s String to validate + * @param name Name of the parameter + * @return Parsed number + * @throws ClientException + */ + public static Long validateLong(String s, String name) throws ClientException { + try { + return Long.valueOf(s); + } catch (NumberFormatException e) { + throw new ClientException("ValidationError", MessageFormat.format("{0} is not a number", name)); } } diff --git a/docs-web-common/src/main/java/com/sismics/util/filter/RequestContextFilter.java b/docs-web-common/src/main/java/com/sismics/util/filter/RequestContextFilter.java index 68e53793..f3eff148 100644 --- a/docs-web-common/src/main/java/com/sismics/util/filter/RequestContextFilter.java +++ b/docs-web-common/src/main/java/com/sismics/util/filter/RequestContextFilter.java @@ -1,6 +1,5 @@ package com.sismics.util.filter; -import java.io.File; import java.io.IOException; import java.text.MessageFormat; @@ -44,20 +43,19 @@ public class RequestContextFilter implements Filter { if (!filterConfig.getServletContext().getServerInfo().startsWith("Grizzly")) { EnvironmentUtil.setWebappContext(true); } - File baseDataDirectory = null; try { - baseDataDirectory = DirectoryUtil.getBaseDataDirectory(); + if (log.isInfoEnabled()) { + log.info(MessageFormat.format("Using base data directory: {0}", DirectoryUtil.getBaseDataDirectory())); + } } catch (Exception e) { log.error("Error initializing base data directory", e); } - if (log.isInfoEnabled()) { - log.info(MessageFormat.format("Using base data directory: {0}", baseDataDirectory.toString())); - } + // Initialize file logger RollingFileAppender fileAppender = new RollingFileAppender(); fileAppender.setName("FILE"); - fileAppender.setFile(DirectoryUtil.getLogDirectory() + File.separator + "docs.log"); + fileAppender.setFile(DirectoryUtil.getLogDirectory().resolve("docs.log").toString()); fileAppender.setLayout(new PatternLayout("%d{DATE} %p %l %m %n")); fileAppender.setThreshold(Level.INFO); fileAppender.setAppend(true); diff --git a/docs-web-common/src/test/java/com/sismics/docs/rest/BaseJerseyTest.java b/docs-web-common/src/test/java/com/sismics/docs/rest/BaseJerseyTest.java index 80fc4fb9..283a76a6 100644 --- a/docs-web-common/src/test/java/com/sismics/docs/rest/BaseJerseyTest.java +++ b/docs-web-common/src/test/java/com/sismics/docs/rest/BaseJerseyTest.java @@ -1,8 +1,6 @@ package com.sismics.docs.rest; -import java.io.File; import java.net.URI; -import java.net.URLDecoder; import javax.ws.rs.core.Application; import javax.ws.rs.core.UriBuilder; @@ -63,8 +61,7 @@ public abstract class BaseJerseyTest extends JerseyTest { clientUtil = new ClientUtil(target()); - String httpRoot = URLDecoder.decode(new File(getClass().getResource("/").getFile()).getAbsolutePath(), "utf-8"); - httpServer = HttpServer.createSimpleServer(httpRoot, "localhost", getPort()); + httpServer = HttpServer.createSimpleServer(getClass().getResource("/").getFile(), "localhost", getPort()); WebappContext context = new WebappContext("GrizzlyContext", "/docs"); context.addFilter("requestContextFilter", RequestContextFilter.class) .addMappingForUrlPatterns(null, "/*"); diff --git a/docs-web-common/src/test/java/com/sismics/docs/rest/util/ClientUtil.java b/docs-web-common/src/test/java/com/sismics/docs/rest/util/ClientUtil.java index 92276494..3f16562c 100644 --- a/docs-web-common/src/test/java/com/sismics/docs/rest/util/ClientUtil.java +++ b/docs-web-common/src/test/java/com/sismics/docs/rest/util/ClientUtil.java @@ -40,7 +40,7 @@ public class ClientUtil { form.param("username", username); form.param("email", username + "@docs.com"); form.param("password", "12345678"); - form.param("time_zone", "Asia/Tokyo"); + form.param("storage_quota", "1000000"); // 1MB quota resource.path("/user").request() .cookie(TokenBasedSecurityFilter.COOKIE_NAME, adminAuthenticationToken) .put(Entity.form(form), JsonObject.class); diff --git a/docs-web/pom.xml b/docs-web/pom.xml index 29016e40..15a7a835 100644 --- a/docs-web/pom.xml +++ b/docs-web/pom.xml @@ -174,6 +174,15 @@ + + + org.apache.maven.plugins + maven-surefire-plugin + + 1 + false + + diff --git a/docs-web/src/dev/resources/config.properties b/docs-web/src/dev/resources/config.properties index a7a2e43f..299540e8 100644 --- a/docs-web/src/dev/resources/config.properties +++ b/docs-web/src/dev/resources/config.properties @@ -1,3 +1,3 @@ api.current_version=${project.version} api.min_version=1.0 -db.version=3 \ No newline at end of file +db.version=4 \ No newline at end of file diff --git a/docs-web/src/main/java/com/sismics/docs/rest/resource/AppResource.java b/docs-web/src/main/java/com/sismics/docs/rest/resource/AppResource.java index b8992d37..eb03376d 100644 --- a/docs-web/src/main/java/com/sismics/docs/rest/resource/AppResource.java +++ b/docs-web/src/main/java/com/sismics/docs/rest/resource/AppResource.java @@ -1,5 +1,8 @@ package com.sismics.docs.rest.resource; +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -19,8 +22,10 @@ import org.apache.log4j.Appender; import org.apache.log4j.Logger; import com.sismics.docs.core.dao.jpa.FileDao; +import com.sismics.docs.core.dao.jpa.UserDao; import com.sismics.docs.core.model.context.AppContext; import com.sismics.docs.core.model.jpa.File; +import com.sismics.docs.core.model.jpa.User; import com.sismics.docs.core.util.ConfigUtil; import com.sismics.docs.core.util.DirectoryUtil; import com.sismics.docs.core.util.jpa.PaginatedList; @@ -165,13 +170,65 @@ public class AppResource extends BaseResource { } // Check if each stored file is valid - java.io.File[] storedFileList = DirectoryUtil.getStorageDirectory().listFiles(); - for (java.io.File storedFile : storedFileList) { - String fileName = storedFile.getName(); - String[] fileNameArray = fileName.split("_"); - if (!fileMap.containsKey(fileNameArray[0])) { - storedFile.delete(); + try (DirectoryStream storedFileList = Files.newDirectoryStream(DirectoryUtil.getStorageDirectory())) { + for (java.nio.file.Path storedFile : storedFileList) { + String fileName = storedFile.getFileName().toString(); + String[] fileNameArray = fileName.split("_"); + if (!fileMap.containsKey(fileNameArray[0])) { + Files.delete(storedFile); + } } + } catch (IOException e) { + throw new ServerException("FileError", "Error deleting orphan files", e); + } + + // Always return OK + JsonObjectBuilder response = Json.createObjectBuilder() + .add("status", "ok"); + return Response.ok().entity(response.build()).build(); + } + + /** + * Recompute the quota for each user. + * + * @return Response + */ + @POST + @Path("batch/recompute_quota") + public Response batchRecomputeQuota() { + if (!authenticate()) { + throw new ForbiddenClientException(); + } + checkBaseFunction(BaseFunction.ADMIN); + + // Get all files + FileDao fileDao = new FileDao(); + List fileList = fileDao.findAll(); + + // Count each file for the corresponding user quota + UserDao userDao = new UserDao(); + Map userMap = new HashMap<>(); + for (File file : fileList) { + java.nio.file.Path storedFile = DirectoryUtil.getStorageDirectory().resolve(file.getId()); + User user = null; + if (userMap.containsKey(file.getUserId())) { + user = userMap.get(file.getUserId()); + } else { + user = userDao.getById(file.getUserId()); + user.setStorageCurrent(0l); + userMap.put(user.getId(), user); + } + + try { + user.setStorageCurrent(user.getStorageCurrent() + Files.size(storedFile)); + } catch (IOException e) { + throw new ServerException("MissingFile", "File does not exist", e); + } + } + + // Save all users + for (User user : userMap.values()) { + userDao.update(user); } // Always return OK diff --git a/docs-web/src/main/java/com/sismics/docs/rest/resource/FileResource.java b/docs-web/src/main/java/com/sismics/docs/rest/resource/FileResource.java index ffdec588..69052f27 100644 --- a/docs-web/src/main/java/com/sismics/docs/rest/resource/FileResource.java +++ b/docs-web/src/main/java/com/sismics/docs/rest/resource/FileResource.java @@ -1,10 +1,10 @@ package com.sismics.docs.rest.resource; import java.io.ByteArrayInputStream; -import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.nio.file.Files; import java.nio.file.Paths; import java.text.MessageFormat; import java.text.SimpleDateFormat; @@ -123,6 +123,11 @@ public class FileResource extends BaseResource { throw new ClientException("InvalidFileType", "File type not recognized"); } + // Validate quota + if (user.getStorageCurrent() + fileData.length > user.getStorageQuota()) { + throw new ClientException("QuotaReached", "Quota limit reached"); + } + try { // Get files of this document FileDao fileDao = new FileDao(); @@ -144,6 +149,10 @@ public class FileResource extends BaseResource { // Save the file FileUtil.save(fileInputStream, file, user.getPrivateKey()); + // Update the user quota + user.setStorageCurrent(user.getStorageCurrent() + fileData.length); + userDao.update(user); + // Raise a new file created event if we have a document if (documentId != null) { FileCreatedAsyncEvent fileCreatedAsyncEvent = new FileCreatedAsyncEvent(); @@ -156,7 +165,8 @@ public class FileResource extends BaseResource { // Always return OK JsonObjectBuilder response = Json.createObjectBuilder() .add("status", "ok") - .add("id", fileId); + .add("id", fileId) + .add("size", fileData.length); return Response.ok().entity(response.build()).build(); } catch (Exception e) { throw new ServerException("FileError", "Error adding a file", e); @@ -206,8 +216,8 @@ public class FileResource extends BaseResource { // Raise a new file created event (it wasn't sent during file creation) try { - java.io.File storedfile = Paths.get(DirectoryUtil.getStorageDirectory().getPath(), id).toFile(); - InputStream fileInputStream = new FileInputStream(storedfile); + java.nio.file.Path storedFile = DirectoryUtil.getStorageDirectory().resolve(id); + InputStream fileInputStream = Files.newInputStream(storedFile); final InputStream responseInputStream = EncryptionUtil.decryptInputStream(fileInputStream, user.getPrivateKey()); FileCreatedAsyncEvent fileCreatedAsyncEvent = new FileCreatedAsyncEvent(); fileCreatedAsyncEvent.setDocument(document); @@ -294,11 +304,16 @@ public class FileResource extends BaseResource { JsonArrayBuilder files = Json.createArrayBuilder(); for (File fileDb : fileList) { - files.add(Json.createObjectBuilder() - .add("id", fileDb.getId()) - .add("mimetype", fileDb.getMimeType()) - .add("document_id", JsonUtil.nullable(fileDb.getDocumentId())) - .add("create_date", fileDb.getCreateDate().getTime())); + try { + files.add(Json.createObjectBuilder() + .add("id", fileDb.getId()) + .add("mimetype", fileDb.getMimeType()) + .add("document_id", JsonUtil.nullable(fileDb.getDocumentId())) + .add("create_date", fileDb.getCreateDate().getTime()) + .add("size", Files.size(DirectoryUtil.getStorageDirectory().resolve(fileDb.getId())))); + } catch (IOException e) { + throw new ServerException("FileError", "Unable to get the size of " + fileDb.getId(), e); + } } JsonObjectBuilder response = Json.createObjectBuilder() @@ -341,6 +356,17 @@ public class FileResource extends BaseResource { // Delete the file fileDao.delete(file.getId()); + // Update the user quota + UserDao userDao = new UserDao(); + User user = userDao.getById(principal.getId()); + java.nio.file.Path storedFile = DirectoryUtil.getStorageDirectory().resolve(id); + try { + user.setStorageCurrent(user.getStorageCurrent() - Files.size(storedFile)); + userDao.update(user); + } catch (IOException e) { + // The file doesn't exists on disk, which is weird, but not fatal + } + // Raise a new file deleted event FileDeletedAsyncEvent fileDeletedAsyncEvent = new FileDeletedAsyncEvent(); fileDeletedAsyncEvent.setFile(file); @@ -396,30 +422,33 @@ public class FileResource extends BaseResource { // Get the stored file - java.io.File storedfile; + java.nio.file.Path storedFile; String mimeType; boolean decrypt = false; if (size != null) { - storedfile = Paths.get(DirectoryUtil.getStorageDirectory().getPath(), fileId + "_" + size).toFile(); + storedFile = DirectoryUtil.getStorageDirectory().resolve(fileId + "_" + size); mimeType = MimeType.IMAGE_JPEG; // Thumbnails are JPEG decrypt = true; // Thumbnails are encrypted - if (!storedfile.exists()) { - storedfile = new java.io.File(getClass().getResource("/image/file.png").getFile()); + if (!Files.exists(storedFile)) { + storedFile = Paths.get(getClass().getResource("/image/file.png").getFile()); mimeType = MimeType.IMAGE_PNG; decrypt = false; } } else { - storedfile = Paths.get(DirectoryUtil.getStorageDirectory().getPath(), fileId).toFile(); + storedFile = DirectoryUtil.getStorageDirectory().resolve(fileId); mimeType = file.getMimeType(); decrypt = true; // Original files are encrypted } // Stream the output and decrypt it if necessary StreamingOutput stream; + // A file is always encrypted by the creator of it User user = userDao.getById(file.getUserId()); + + // Write the decrypted file to the output try { - InputStream fileInputStream = new FileInputStream(storedfile); + InputStream fileInputStream = Files.newInputStream(storedFile); final InputStream responseInputStream = decrypt ? EncryptionUtil.decryptInputStream(fileInputStream, user.getPrivateKey()) : fileInputStream; @@ -484,8 +513,8 @@ public class FileResource extends BaseResource { // Add each file to the ZIP stream int index = 0; for (File file : fileList) { - java.io.File storedfile = Paths.get(DirectoryUtil.getStorageDirectory().getPath(), file.getId()).toFile(); - InputStream fileInputStream = new FileInputStream(storedfile); + java.nio.file.Path storedfile = DirectoryUtil.getStorageDirectory().resolve(file.getId()); + InputStream fileInputStream = Files.newInputStream(storedfile); // Add the decrypted file to the ZIP stream // Files are encrypted by the creator of them diff --git a/docs-web/src/main/java/com/sismics/docs/rest/resource/UserResource.java b/docs-web/src/main/java/com/sismics/docs/rest/resource/UserResource.java index b1fe41eb..a4da17bb 100644 --- a/docs-web/src/main/java/com/sismics/docs/rest/resource/UserResource.java +++ b/docs-web/src/main/java/com/sismics/docs/rest/resource/UserResource.java @@ -64,7 +64,8 @@ public class UserResource extends BaseResource { public Response register( @FormParam("username") String username, @FormParam("password") String password, - @FormParam("email") String email) { + @FormParam("email") String email, + @FormParam("storage_quota") String storageQuotaStr) { if (!authenticate()) { throw new ForbiddenClientException(); @@ -76,6 +77,7 @@ public class UserResource extends BaseResource { ValidationUtil.validateAlphanumeric(username, "username"); password = ValidationUtil.validateLength(password, "password", 8, 50); email = ValidationUtil.validateLength(email, "email", 3, 50); + Long storageQuota = ValidationUtil.validateLong(storageQuotaStr, "storage_quota"); ValidationUtil.validateEmail(email, "email"); // Create the user @@ -84,6 +86,8 @@ public class UserResource extends BaseResource { user.setUsername(username); user.setPassword(password); user.setEmail(email); + user.setStorageQuota(storageQuota); + user.setStorageCurrent(0l); try { user.setPrivateKey(EncryptionUtil.generatePrivateKey()); } catch (NoSuchAlgorithmException e) { @@ -135,9 +139,9 @@ public class UserResource extends BaseResource { if (email != null) { user.setEmail(email); } - user = userDao.update(user); + // Change the password if (StringUtils.isNotBlank(password)) { user.setPassword(password); userDao.updatePassword(user); @@ -162,7 +166,8 @@ public class UserResource extends BaseResource { public Response update( @PathParam("username") String username, @FormParam("password") String password, - @FormParam("email") String email) { + @FormParam("email") String email, + @FormParam("storage_quota") String storageQuotaStr) { if (!authenticate()) { throw new ForbiddenClientException(); @@ -184,11 +189,14 @@ public class UserResource extends BaseResource { if (email != null) { user.setEmail(email); } - + if (StringUtils.isNotBlank(storageQuotaStr)) { + Long storageQuota = ValidationUtil.validateLong(storageQuotaStr, "storage_quota"); + user.setStorageQuota(storageQuota); + } user = userDao.update(user); + // Change the password if (StringUtils.isNotBlank(password)) { - // Change the password user.setPassword(password); userDao.updatePassword(user); } @@ -406,7 +414,9 @@ public class UserResource extends BaseResource { UserDao userDao = new UserDao(); User user = userDao.getById(principal.getId()); response.add("username", user.getUsername()) - .add("email", user.getEmail()); + .add("email", user.getEmail()) + .add("storage_quota", user.getStorageQuota()) + .add("storage_current", user.getStorageCurrent()); JsonArrayBuilder baseFunctions = Json.createArrayBuilder(); for (String baseFunction : ((UserPrincipal) principal).getBaseFunctionSet()) { baseFunctions.add(baseFunction); @@ -441,7 +451,9 @@ public class UserResource extends BaseResource { JsonObjectBuilder response = Json.createObjectBuilder() .add("username", user.getUsername()) - .add("email", user.getEmail()); + .add("email", user.getEmail()) + .add("storage_quota", user.getStorageQuota()) + .add("storage_current", user.getStorageCurrent()); return Response.ok().entity(response.build()).build(); } @@ -477,6 +489,8 @@ public class UserResource extends BaseResource { .add("id", userDto.getId()) .add("username", userDto.getUsername()) .add("email", userDto.getEmail()) + .add("storage_quota", userDto.getStorageQuota()) + .add("storage_current", userDto.getStorageCurrent()) .add("create_date", userDto.getCreateTimestamp())); } diff --git a/docs-web/src/main/webapp/src/app/docs/controller/DocumentDefault.js b/docs-web/src/main/webapp/src/app/docs/controller/DocumentDefault.js index 361ef241..6d9c7715 100644 --- a/docs-web/src/main/webapp/src/app/docs/controller/DocumentDefault.js +++ b/docs-web/src/main/webapp/src/app/docs/controller/DocumentDefault.js @@ -3,7 +3,7 @@ /** * Document default controller. */ -angular.module('docs').controller('DocumentDefault', function($scope, $state, Restangular, $upload) { +angular.module('docs').controller('DocumentDefault', function($scope, $rootScope, $state, Restangular, $upload) { // Load app data Restangular.one('app').get().then(function(data) { $scope.app = data; @@ -73,7 +73,18 @@ angular.module('docs').controller('DocumentDefault', function($scope, $state, Re newfile.progress = parseInt(100.0 * e.loaded / e.total); }) .success(function (data) { + // Update local model with real data newfile.id = data.id; + newfile.size = data.size; + + // New file uploaded, increase used quota + $rootScope.userInfo.storage_current += data.size; + }) + .error(function (data) { + newfile.status = 'Upload error'; + if (data.type == 'QuotaReached') { + newfile.status += ' - Quota reached'; + } }); }; @@ -90,7 +101,11 @@ angular.module('docs').controller('DocumentDefault', function($scope, $state, Re $scope.deleteFile = function ($event, file) { $event.stopPropagation(); - Restangular.one('file', file.id).remove().then(function () { + Restangular.one('file', file.id).remove().then(function() { + // File deleted, decrease used quota + $rootScope.userInfo.storage_current -= file.size; + + // Update local data $scope.loadFiles(); }); return false; diff --git a/docs-web/src/main/webapp/src/app/docs/controller/DocumentEdit.js b/docs-web/src/main/webapp/src/app/docs/controller/DocumentEdit.js index c9141ec7..139689ef 100644 --- a/docs-web/src/main/webapp/src/app/docs/controller/DocumentEdit.js +++ b/docs-web/src/main/webapp/src/app/docs/controller/DocumentEdit.js @@ -142,8 +142,8 @@ angular.module('docs').controller('DocumentEdit', function($rootScope, $scope, $ success: function(response) { deferred.resolve(response); }, - error: function(jqXHR, textStatus, errorThrown) { - deferred.reject(errorThrown); + error: function(jqXHR) { + deferred.reject(jqXHR); }, xhr: function() { var myXhr = $.ajaxSettings.xhr(); @@ -155,8 +155,23 @@ angular.module('docs').controller('DocumentEdit', function($rootScope, $scope, $ // Update progress bar and title on progress var startProgress = $scope.fileProgress; - deferred.promise.then(null, null, function(e) { - var done = 1 - (e.total - e.position) / e.total; + deferred.promise.then(function(data) { + // New file uploaded, increase used quota + $rootScope.userInfo.storage_current += data.size; + }, function(data) { + // Error uploading a file, we stop here + $scope.alerts.unshift({ + type: 'danger', + msg: 'Document successfully ' + ($scope.isEdit() ? 'edited' : 'added') + ' but some files cannot be uploaded' + + (data.responseJSON.type == 'QuotaReached' ? ' - Quota reached' : '') + }); + + // Reset view and title + $scope.fileIsUploading = false; + $scope.fileProgress = 0; + $rootScope.pageTitle = 'Sismics Docs'; + }, function(e) { + var done = 1 - (e.total - e.loaded) / e.total; var chunk = 100 / _.size($scope.newFiles); $scope.fileProgress = startProgress + done * chunk; $rootScope.pageTitle = Math.round($scope.fileProgress) + '% - Sismics Docs'; @@ -170,7 +185,7 @@ angular.module('docs').controller('DocumentEdit', function($rootScope, $scope, $ var then = function() { key++; if ($scope.newFiles[key]) { - sendFile(key).then(then); // TODO Handle upload error + sendFile(key).then(then); } else { $scope.fileIsUploading = false; $scope.fileProgress = 0; diff --git a/docs-web/src/main/webapp/src/app/docs/controller/DocumentViewContent.js b/docs-web/src/main/webapp/src/app/docs/controller/DocumentViewContent.js index 44fbe3ff..6bddae9b 100644 --- a/docs-web/src/main/webapp/src/app/docs/controller/DocumentViewContent.js +++ b/docs-web/src/main/webapp/src/app/docs/controller/DocumentViewContent.js @@ -3,7 +3,7 @@ /** * Document view content controller. */ -angular.module('docs').controller('DocumentViewContent', function ($scope, $stateParams, Restangular, $dialog, $state, $upload) { +angular.module('docs').controller('DocumentViewContent', function ($scope, $rootScope, $stateParams, Restangular, $dialog, $state, $upload) { /** * Configuration for file sorting. */ @@ -55,6 +55,10 @@ angular.module('docs').controller('DocumentViewContent', function ($scope, $stat $dialog.messageBox(title, msg, btns, function (result) { if (result == 'ok') { Restangular.one('file', file.id).remove().then(function () { + // File deleted, decrease used quota + $rootScope.userInfo.storage_current -= file.size; + + // Update local data $scope.loadFiles(); }); } @@ -109,11 +113,22 @@ angular.module('docs').controller('DocumentViewContent', function ($scope, $stat id: $stateParams.id } }) - .progress(function (e) { + .progress(function(e) { newfile.progress = parseInt(100.0 * e.loaded / e.total); }) - .success(function (data) { + .success(function(data) { + // Update local model with real data newfile.id = data.id; + newfile.size = data.size; + + // New file uploaded, increase used quota + $rootScope.userInfo.storage_current += data.size; + }) + .error(function (data) { + newfile.status = 'Upload error'; + if (data.type == 'QuotaReached') { + newfile.status += ' - Quota reached'; + } }); }; }); \ No newline at end of file diff --git a/docs-web/src/main/webapp/src/app/docs/controller/SettingsUserEdit.js b/docs-web/src/main/webapp/src/app/docs/controller/SettingsUserEdit.js index 91537989..792990c2 100644 --- a/docs-web/src/main/webapp/src/app/docs/controller/SettingsUserEdit.js +++ b/docs-web/src/main/webapp/src/app/docs/controller/SettingsUserEdit.js @@ -16,6 +16,7 @@ angular.module('docs').controller('SettingsUserEdit', function($scope, $dialog, */ if ($scope.isEdit()) { Restangular.one('user', $stateParams.username).get().then(function(data) { + data.storage_quota /= 1000000; $scope.user = data; }); } @@ -25,15 +26,17 @@ angular.module('docs').controller('SettingsUserEdit', function($scope, $dialog, */ $scope.edit = function() { var promise = null; + var user = angular.copy($scope.user); + user.storage_quota *= 1000000; if ($scope.isEdit()) { promise = Restangular .one('user', $stateParams.username) - .post('', $scope.user); + .post('', user); } else { promise = Restangular .one('user') - .put($scope.user); + .put(user); } promise.then(function() { diff --git a/docs-web/src/main/webapp/src/index.html b/docs-web/src/main/webapp/src/index.html index 9fb47b64..c71cf81d 100644 --- a/docs-web/src/main/webapp/src/index.html +++ b/docs-web/src/main/webapp/src/index.html @@ -71,7 +71,7 @@ -