Merge pull request #110 from sismics/master

Push to production
This commit is contained in:
Benjamin Gamard 2016-06-16 21:38:14 +02:00 committed by GitHub
commit 05bfaa0035
48 changed files with 1341 additions and 512 deletions

View File

@ -3,7 +3,7 @@ buildscript {
jcenter() jcenter()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:2.1.0' classpath 'com.android.tools.build:gradle:2.1.2'
} }
} }
apply plugin: 'com.android.application' apply plugin: 'com.android.application'
@ -14,7 +14,7 @@ repositories {
android { android {
compileSdkVersion 23 compileSdkVersion 23
buildToolsVersion '23.0.3' buildToolsVersion '24'
defaultConfig { defaultConfig {
minSdkVersion 14 minSdkVersion 14
@ -50,13 +50,13 @@ android {
dependencies { dependencies {
compile fileTree(dir: 'libs', include: '*.jar') compile fileTree(dir: 'libs', include: '*.jar')
compile 'com.android.support:appcompat-v7:23.3.0' compile 'com.android.support:appcompat-v7:23.4.0'
compile 'com.android.support:recyclerview-v7:23.3.0' compile 'com.android.support:recyclerview-v7:23.4.0'
compile 'com.android.support:design:23.3.0' compile 'com.android.support:design:23.4.0'
compile 'it.sephiroth.android.library.imagezoom:imagezoom:1.0.5' compile 'it.sephiroth.android.library.imagezoom:imagezoom:1.0.5'
compile 'org.greenrobot:eventbus:3.0.0' compile 'org.greenrobot:eventbus:3.0.0'
compile 'com.squareup.picasso:picasso:2.5.2' compile 'com.squareup.picasso:picasso:2.5.2'
compile 'com.squareup.okhttp3:okhttp:3.1.1' compile 'com.squareup.okhttp3:okhttp:3.3.1'
compile "com.squareup.okhttp3:okhttp-urlconnection:3.1.1" compile "com.squareup.okhttp3:okhttp-urlconnection:3.3.1"
compile 'com.jakewharton.picasso:picasso2-okhttp3-downloader:1.0.2' compile 'com.jakewharton.picasso:picasso2-okhttp3-downloader:1.0.2'
} }

View File

@ -101,7 +101,7 @@ public class DocumentEditActivity extends AppCompatActivity {
finish(); finish();
return; return;
} }
JSONArray tagArray = tags.optJSONArray("stats"); JSONArray tagArray = tags.optJSONArray("tags");
List<JSONObject> tagList = new ArrayList<>(); List<JSONObject> tagList = new ArrayList<>();
for (int i = 0; i < tagArray.length(); i++) { for (int i = 0; i < tagArray.length(); i++) {

View File

@ -16,6 +16,7 @@ import android.support.v7.app.AppCompatActivity;
import android.support.v7.widget.Toolbar; import android.support.v7.widget.Toolbar;
import android.text.TextUtils; import android.text.TextUtils;
import android.text.format.DateFormat; import android.text.format.DateFormat;
import android.text.method.LinkMovementMethod;
import android.view.ContextMenu; import android.view.ContextMenu;
import android.view.Menu; import android.view.Menu;
import android.view.MenuInflater; import android.view.MenuInflater;
@ -51,7 +52,7 @@ import com.sismics.docs.resource.FileResource;
import com.sismics.docs.service.FileUploadService; import com.sismics.docs.service.FileUploadService;
import com.sismics.docs.util.NetworkUtil; import com.sismics.docs.util.NetworkUtil;
import com.sismics.docs.util.PreferenceUtil; import com.sismics.docs.util.PreferenceUtil;
import com.sismics.docs.util.TagUtil; import com.sismics.docs.util.SpannableUtil;
import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.Subscribe;
@ -176,9 +177,11 @@ public class DocumentViewActivity extends AppCompatActivity {
} }
// Fill the layout // Fill the layout
// Create date
TextView createdDateTextView = (TextView) findViewById(R.id.createdDateTextView); TextView createdDateTextView = (TextView) findViewById(R.id.createdDateTextView);
createdDateTextView.setText(date); createdDateTextView.setText(date);
// Description
TextView descriptionTextView = (TextView) findViewById(R.id.descriptionTextView); TextView descriptionTextView = (TextView) findViewById(R.id.descriptionTextView);
if (description.isEmpty() || document.isNull("description")) { if (description.isEmpty() || document.isNull("description")) {
descriptionTextView.setVisibility(View.GONE); descriptionTextView.setVisibility(View.GONE);
@ -187,17 +190,20 @@ public class DocumentViewActivity extends AppCompatActivity {
descriptionTextView.setText(description); descriptionTextView.setText(description);
} }
// Tags
TextView tagTextView = (TextView) findViewById(R.id.tagTextView); TextView tagTextView = (TextView) findViewById(R.id.tagTextView);
if (tags.length() == 0) { if (tags.length() == 0) {
tagTextView.setVisibility(View.GONE); tagTextView.setVisibility(View.GONE);
} else { } else {
tagTextView.setVisibility(View.VISIBLE); tagTextView.setVisibility(View.VISIBLE);
tagTextView.setText(TagUtil.buildSpannable(tags)); tagTextView.setText(SpannableUtil.buildSpannableTags(tags));
} }
// Language
ImageView languageImageView = (ImageView) findViewById(R.id.languageImageView); ImageView languageImageView = (ImageView) findViewById(R.id.languageImageView);
languageImageView.setImageResource(getResources().getIdentifier(language, "drawable", getPackageName())); languageImageView.setImageResource(getResources().getIdentifier(language, "drawable", getPackageName()));
// Shared status
ImageView sharedImageView = (ImageView) findViewById(R.id.sharedImageView); ImageView sharedImageView = (ImageView) findViewById(R.id.sharedImageView);
sharedImageView.setVisibility(shared ? View.VISIBLE : View.GONE); sharedImageView.setVisibility(shared ? View.VISIBLE : View.GONE);
@ -642,10 +648,10 @@ public class DocumentViewActivity extends AppCompatActivity {
} }
// Action only available if the document is writable // Action only available if the document is writable
findViewById(R.id.actionEditDocument).setVisibility(writable ? View.VISIBLE : View.INVISIBLE); findViewById(R.id.actionEditDocument).setVisibility(writable ? View.VISIBLE : View.GONE);
findViewById(R.id.actionUploadFile).setVisibility(writable ? View.VISIBLE : View.INVISIBLE); findViewById(R.id.actionUploadFile).setVisibility(writable ? View.VISIBLE : View.GONE);
findViewById(R.id.actionSharing).setVisibility(writable ? View.VISIBLE : View.INVISIBLE); findViewById(R.id.actionSharing).setVisibility(writable ? View.VISIBLE : View.GONE);
findViewById(R.id.actionDelete).setVisibility(writable ? View.VISIBLE : View.INVISIBLE); findViewById(R.id.actionDelete).setVisibility(writable ? View.VISIBLE : View.GONE);
// ACLs // ACLs
ListView aclListView = (ListView) findViewById(R.id.aclListView); ListView aclListView = (ListView) findViewById(R.id.aclListView);
@ -679,10 +685,54 @@ public class DocumentViewActivity extends AppCompatActivity {
startActivity(intent); startActivity(intent);
} }
}); });
// Contributors
TextView contributorsTextView = (TextView) findViewById(R.id.contributorsTextView);
contributorsTextView.setText(SpannableUtil.buildSpannableContributors(document.optJSONArray("contributors")));
// Relations
JSONArray relations = document.optJSONArray("relations");
if (relations.length() > 0) {
TextView relationsTextView = (TextView) findViewById(R.id.relationsTextView);
relationsTextView.setMovementMethod(LinkMovementMethod.getInstance());
relationsTextView.setText(SpannableUtil.buildSpannableRelations(relations));
} else {
findViewById(R.id.relationsLayout).setVisibility(View.GONE);
}
// Additional dublincore metadata
displayDublincoreMetadata(R.id.subjectTextView, R.id.subjectLayout, "subject");
displayDublincoreMetadata(R.id.identifierTextView, R.id.identifierLayout, "identifier");
displayDublincoreMetadata(R.id.publisherTextView, R.id.publisherLayout, "publisher");
displayDublincoreMetadata(R.id.formatTextView, R.id.formatLayout, "format");
displayDublincoreMetadata(R.id.sourceTextView, R.id.sourceLayout, "source");
displayDublincoreMetadata(R.id.typeTextView, R.id.typeLayout, "type");
displayDublincoreMetadata(R.id.coverageTextView, R.id.coverageLayout, "coverage");
displayDublincoreMetadata(R.id.rightsTextView, R.id.rightsLayout, "rights");
} }
}); });
} }
/**
* Display a dublincore metadata.
*
* @param textViewId TextView ID
* @param blockViewId View ID
* @param name Name
*/
private void displayDublincoreMetadata(int textViewId, int blockViewId, String name) {
if (document == null) return;
String value = document.optString(name);
if (document.isNull(name) || value.isEmpty()) {
findViewById(blockViewId).setVisibility(View.GONE);
return;
}
findViewById(blockViewId).setVisibility(View.VISIBLE);
TextView textView = (TextView) findViewById(textViewId);
textView.setText(value);
}
@Override @Override
public void onCreateContextMenu(ContextMenu menu, View view, ContextMenu.ContextMenuInfo menuInfo) { public void onCreateContextMenu(ContextMenu menu, View view, ContextMenu.ContextMenuInfo menuInfo) {
switch (view.getId()) { switch (view.getId()) {

View File

@ -9,7 +9,7 @@ import android.widget.ImageView;
import android.widget.TextView; import android.widget.TextView;
import com.sismics.docs.R; import com.sismics.docs.R;
import com.sismics.docs.util.TagUtil; import com.sismics.docs.util.SpannableUtil;
import org.json.JSONArray; import org.json.JSONArray;
import org.json.JSONObject; import org.json.JSONObject;
@ -69,7 +69,7 @@ public class DocListAdapter extends RecyclerView.Adapter<DocListAdapter.ViewHold
holder.titleTextView.setText(document.optString("title")); holder.titleTextView.setText(document.optString("title"));
JSONArray tags = document.optJSONArray("tags"); JSONArray tags = document.optJSONArray("tags");
holder.subtitleTextView.setText(TagUtil.buildSpannable(tags)); holder.subtitleTextView.setText(SpannableUtil.buildSpannableTags(tags));
String date = DateFormat.getDateFormat(holder.dateTextView.getContext()).format(new Date(document.optLong("create_date"))); String date = DateFormat.getDateFormat(holder.dateTextView.getContext()).format(new Date(document.optLong("create_date")));
holder.dateTextView.setText(date); holder.dateTextView.setText(date);

View File

@ -73,7 +73,7 @@ public class SearchFragment extends DialogFragment {
dialog.cancel(); dialog.cancel();
return dialog; return dialog;
} }
JSONArray tagArray = tags.optJSONArray("stats"); JSONArray tagArray = tags.optJSONArray("tags");
List<JSONObject> tagList = new ArrayList<>(); List<JSONObject> tagList = new ArrayList<>();
for (int i = 0; i < tagArray.length(); i++) { for (int i = 0; i < tagArray.length(); i++) {

View File

@ -0,0 +1,33 @@
package com.sismics.docs.ui.view;
import android.content.Context;
import android.util.AttributeSet;
import android.view.ViewGroup;
import android.widget.ListView;
/**
* Non-scrollable ListView.
* All items are visible from the start.
*
* @author http://stackoverflow.com/questions/18813296/non-scrollable-listview-inside-scrollview/24629341#24629341
*/
public class NonScrollListView extends ListView {
public NonScrollListView(Context context) {
super(context);
}
public NonScrollListView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public NonScrollListView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
}
@Override
public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int heightMeasureSpec_custom = MeasureSpec.makeMeasureSpec(
Integer.MAX_VALUE >> 2, MeasureSpec.AT_MOST);
super.onMeasure(widthMeasureSpec, heightMeasureSpec_custom);
ViewGroup.LayoutParams params = getLayoutParams();
params.height = getMeasuredHeight();
}
}

View File

@ -0,0 +1,85 @@
package com.sismics.docs.util;
import android.graphics.Color;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.style.BackgroundColorSpan;
import android.text.style.ForegroundColorSpan;
import org.json.JSONArray;
import org.json.JSONObject;
/**
* Utility class for spannable.
*
* @author bgamard.
*/
public class SpannableUtil {
/**
* Create a colored spannable from tags.
*
* @param tags Tags
* @return Colored spannable
*/
public static Spannable buildSpannableTags(JSONArray tags) {
return buildSpannable(tags, "name", "color");
}
/**
* Create a spannable for contributors.
*
* @param contributors Contributors
* @return Spannable
*/
public static Spannable buildSpannableContributors(JSONArray contributors) {
return buildSpannable(contributors, "username", null);
}
/**
* Create a spannable for relations.
*
* @param relations Relations
* @return Spannable
*/
public static Spannable buildSpannableRelations(JSONArray relations) {
return buildSpannable(relations, "title", null);
}
/**
* Create a spannable from a JSONArray.
*
* @param array JSONArray
* @param valueName Name of the value part
* @param colorName Name of the color part (optional)
* @return Spannable
*/
private static Spannable buildSpannable(JSONArray array, String valueName, String colorName) {
SpannableStringBuilder builder = new SpannableStringBuilder();
for (int i = 0; i < array.length(); i++) {
final JSONObject tag = array.optJSONObject(i);
int start = builder.length();
builder.append(" ").append(tag.optString(valueName)).append(" ");
builder.setSpan(new ForegroundColorSpan(Color.WHITE), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.setSpan(new BackgroundColorSpan(Color.parseColor(tag.optString(colorName, "#5bc0de"))), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
/*
TODO : Make tags, relations and contributors clickable
builder.setSpan(new ClickableSpan() {
@Override
public void onClick(View widget) {
}
@Override
public void updateDrawState(TextPaint ds) {
super.updateDrawState(ds);
ds.setColor(Color.WHITE);
ds.setUnderlineText(false);
}
}, start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);*/
builder.append(" ");
}
return builder;
}
}

View File

@ -1,39 +0,0 @@
package com.sismics.docs.util;
import android.graphics.Color;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.style.BackgroundColorSpan;
import android.text.style.ForegroundColorSpan;
import org.json.JSONArray;
import org.json.JSONObject;
/**
* Utility class for tags.
*
* @author bgamard.
*/
public class TagUtil {
/**
* Create a colored spannable from tags.
*
* @param tags Tags
* @return Colored spannable
*/
public static Spannable buildSpannable(JSONArray tags) {
SpannableStringBuilder builder = new SpannableStringBuilder();
for (int i = 0; i < tags.length(); i++) {
JSONObject tag = tags.optJSONObject(i);
int start = builder.length();
builder.append(" ").append(tag.optString("name")).append(" ");
builder.setSpan(new ForegroundColorSpan(Color.WHITE), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.setSpan(new BackgroundColorSpan(Color.parseColor(tag.optString("color"))), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.append(" ");
}
return builder;
}
}

View File

@ -142,15 +142,19 @@
<!-- Right drawer --> <!-- Right drawer -->
<LinearLayout <ScrollView
android:id="@+id/right_drawer" android:id="@+id/right_drawer"
android:layout_width="300dp" android:layout_width="300dp"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_gravity="end"
android:orientation="vertical"
android:clickable="true" android:clickable="true"
android:background="#fff" android:background="#fff"
android:elevation="5dp"> android:elevation="5dp"
android:layout_gravity="end">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- Actions --> <!-- Actions -->
@ -166,28 +170,6 @@
android:orientation="horizontal" android:orientation="horizontal"
style="?android:buttonBarStyle"> style="?android:buttonBarStyle">
<Button
android:id="@+id/actionEditDocument"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:drawableTop="@drawable/ic_create_grey600_24dp"
style="?android:buttonBarButtonStyle"
android:text="@string/edit_document"
android:textColor="#ff5a595b"
android:textAllCaps="false"
android:layout_margin="8dp"/>
<Button
android:id="@+id/actionUploadFile"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:drawableTop="@drawable/ic_file_upload_grey600_24dp"
style="?android:buttonBarButtonStyle"
android:text="@string/upload_file"
android:textColor="#ff5a595b"
android:textAllCaps="false"
android:layout_margin="8dp"/>
<Button <Button
android:id="@+id/actionDownload" android:id="@+id/actionDownload"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@ -199,6 +181,28 @@
android:textAllCaps="false" android:textAllCaps="false"
android:layout_margin="8dp"/> android:layout_margin="8dp"/>
<Button
android:id="@+id/actionExportPdf"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:drawableTop="@drawable/ic_description_grey600_24dp"
style="?android:buttonBarButtonStyle"
android:text="@string/export_pdf"
android:textColor="#ff5a595b"
android:textAllCaps="false"
android:layout_margin="8dp"/>
<Button
android:id="@+id/actionAuditLog"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:drawableTop="@drawable/ic_assignment_grey600_24dp"
style="?android:buttonBarButtonStyle"
android:text="@string/activity"
android:textColor="#ff5a595b"
android:textAllCaps="false"
android:layout_margin="8dp"/>
</LinearLayout> </LinearLayout>
<LinearLayout <LinearLayout
@ -208,12 +212,23 @@
style="?android:buttonBarStyle"> style="?android:buttonBarStyle">
<Button <Button
android:id="@+id/actionExportPdf" android:id="@+id/actionEditDocument"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:drawableTop="@drawable/ic_description_grey600_24dp" android:drawableTop="@drawable/ic_create_grey600_24dp"
style="?android:buttonBarButtonStyle" style="?android:buttonBarButtonStyle"
android:text="@string/export_pdf" android:text="@string/edit_document"
android:textColor="#ff5a595b"
android:textAllCaps="false"
android:layout_margin="0dp"/>
<Button
android:id="@+id/actionUploadFile"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:drawableTop="@drawable/ic_file_upload_grey600_24dp"
style="?android:buttonBarButtonStyle"
android:text="@string/upload_file"
android:textColor="#ff5a595b" android:textColor="#ff5a595b"
android:textAllCaps="false" android:textAllCaps="false"
android:layout_margin="0dp"/> android:layout_margin="0dp"/>
@ -229,17 +244,6 @@
android:textAllCaps="false" android:textAllCaps="false"
android:layout_margin="0dp"/> android:layout_margin="0dp"/>
<Button
android:id="@+id/actionAuditLog"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:drawableTop="@drawable/ic_assignment_grey600_24dp"
style="?android:buttonBarButtonStyle"
android:text="@string/activity"
android:textColor="#ff5a595b"
android:textAllCaps="false"
android:layout_margin="0dp"/>
<Button <Button
android:id="@+id/actionDelete" android:id="@+id/actionDelete"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@ -285,6 +289,8 @@
android:id="@+id/createdDateTextView" android:id="@+id/createdDateTextView"
android:layout_toRightOf="@id/createdDateLabel" android:layout_toRightOf="@id/createdDateLabel"
android:layout_toEndOf="@id/createdDateLabel" android:layout_toEndOf="@id/createdDateLabel"
android:layout_toLeftOf="@id/sharedImageView"
android:layout_toStartOf="@id/sharedImageView"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="24dp" android:layout_height="24dp"
android:gravity="center_vertical" android:gravity="center_vertical"
@ -332,6 +338,7 @@
android:fontFamily="sans-serif-light"/> android:fontFamily="sans-serif-light"/>
<ImageView <ImageView
android:contentDescription="@string/shared"
android:id="@+id/sharedImageView" android:id="@+id/sharedImageView"
android:layout_width="24dp" android:layout_width="24dp"
android:layout_height="24dp" android:layout_height="24dp"
@ -343,6 +350,7 @@
android:layout_toStartOf="@+id/languageImageView"/> android:layout_toStartOf="@+id/languageImageView"/>
<ImageView <ImageView
android:contentDescription="@string/language"
android:id="@+id/languageImageView" android:id="@+id/languageImageView"
android:layout_width="24dp" android:layout_width="24dp"
android:layout_height="24dp" android:layout_height="24dp"
@ -352,6 +360,278 @@
</RelativeLayout> </RelativeLayout>
<!-- Additional dublincore metadata -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingLeft="12dp"
android:paddingRight="12dp"
android:paddingBottom="12dp">
<!-- Subject -->
<LinearLayout
android:id="@+id/subjectLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_weight="0.33"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginRight="8dp"
android:layout_marginEnd="8dp"
android:fontFamily="sans-serif"
android:text="@string/subject"/>
<TextView
android:id="@+id/subjectTextView"
android:layout_weight="0.67"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-light"/>
</LinearLayout>
<!-- Identifier -->
<LinearLayout
android:id="@+id/identifierLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_weight="0.33"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginRight="8dp"
android:layout_marginEnd="8dp"
android:fontFamily="sans-serif"
android:text="@string/identifier"/>
<TextView
android:id="@+id/identifierTextView"
android:layout_weight="0.67"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-light"/>
</LinearLayout>
<!-- Publisher -->
<LinearLayout
android:id="@+id/publisherLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_weight="0.33"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginRight="8dp"
android:layout_marginEnd="8dp"
android:fontFamily="sans-serif"
android:text="@string/publisher"/>
<TextView
android:id="@+id/publisherTextView"
android:layout_weight="0.67"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-light"/>
</LinearLayout>
<!-- Format -->
<LinearLayout
android:id="@+id/formatLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_weight="0.33"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginRight="8dp"
android:layout_marginEnd="8dp"
android:fontFamily="sans-serif"
android:text="@string/format"/>
<TextView
android:id="@+id/formatTextView"
android:layout_weight="0.67"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-light"/>
</LinearLayout>
<!-- Source -->
<LinearLayout
android:id="@+id/sourceLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_weight="0.33"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginRight="8dp"
android:layout_marginEnd="8dp"
android:fontFamily="sans-serif"
android:text="@string/source"/>
<TextView
android:id="@+id/sourceTextView"
android:layout_weight="0.67"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-light"/>
</LinearLayout>
<!-- Type -->
<LinearLayout
android:id="@+id/typeLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_weight="0.33"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginRight="8dp"
android:layout_marginEnd="8dp"
android:fontFamily="sans-serif"
android:text="@string/type"/>
<TextView
android:id="@+id/typeTextView"
android:layout_weight="0.67"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-light"/>
</LinearLayout>
<!-- Coverage -->
<LinearLayout
android:id="@+id/coverageLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_weight="0.33"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginRight="8dp"
android:layout_marginEnd="8dp"
android:fontFamily="sans-serif"
android:text="@string/coverage"/>
<TextView
android:id="@+id/coverageTextView"
android:layout_weight="0.67"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-light"/>
</LinearLayout>
<!-- Rights -->
<LinearLayout
android:id="@+id/rightsLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_weight="0.33"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginRight="8dp"
android:layout_marginEnd="8dp"
android:fontFamily="sans-serif"
android:text="@string/rights"/>
<TextView
android:id="@+id/rightsTextView"
android:layout_weight="0.67"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-light"/>
</LinearLayout>
<!-- Contributors -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_weight="0.33"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginRight="8dp"
android:layout_marginEnd="8dp"
android:fontFamily="sans-serif"
android:text="@string/contributors"/>
<TextView
android:id="@+id/contributorsTextView"
android:layout_weight="0.67"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-light"/>
</LinearLayout>
<!-- Relations -->
<LinearLayout
android:id="@+id/relationsLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="2dp">
<TextView
android:layout_weight="0.33"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginRight="8dp"
android:layout_marginEnd="8dp"
android:fontFamily="sans-serif"
android:text="@string/relations"/>
<TextView
android:id="@+id/relationsTextView"
android:layout_weight="0.67"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-light"/>
</LinearLayout>
</LinearLayout>
<View <View
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="1dp" android:layout_height="1dp"
@ -369,14 +649,16 @@
android:text="@string/who_can_access" android:text="@string/who_can_access"
android:layout_margin="12dp"/> android:layout_margin="12dp"/>
<ListView <com.sismics.docs.ui.view.NonScrollListView
android:id="@+id/aclListView" android:id="@+id/aclListView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="wrap_content"
android:choiceMode="singleChoice" android:choiceMode="singleChoice"
android:divider="@android:color/transparent" android:divider="@android:color/transparent"
android:dividerHeight="0dp"/> android:dividerHeight="0dp"/>
</LinearLayout> </LinearLayout>
</ScrollView>
</android.support.v4.widget.DrawerLayout> </android.support.v4.widget.DrawerLayout>

View File

@ -132,5 +132,17 @@
<string name="storage_quota">Storage quota</string> <string name="storage_quota">Storage quota</string>
<string name="storage_display">%1$d/%2$d MB</string> <string name="storage_display">%1$d/%2$d MB</string>
<string name="validation_code">Validation code</string> <string name="validation_code">Validation code</string>
<string name="shared">Shared</string>
<string name="language">Language</string>
<string name="coverage">Coverage</string>
<string name="type">Type</string>
<string name="source">Source</string>
<string name="format">Format</string>
<string name="publisher">Publisher</string>
<string name="identifier">Identifier</string>
<string name="subject">Subject</string>
<string name="rights">Rights</string>
<string name="contributors">Contributors</string>
<string name="relations">Relations</string>
</resources> </resources>

View File

@ -12,6 +12,8 @@
# Default value: -Xmx10248m -XX:MaxPermSize=256m # Default value: -Xmx10248m -XX:MaxPermSize=256m
# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 # org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
org.gradle.jvmargs=-Xmx3072m
# When configured, Gradle will run in incubating parallel mode. # When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit # This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects

View File

@ -13,5 +13,10 @@ public enum ConfigType {
/** /**
* Theme configuration. * Theme configuration.
*/ */
THEME THEME,
/**
* Guest login.
*/
GUEST_LOGIN
} }

View File

@ -30,6 +30,11 @@ public class Constants {
*/ */
public static final String LUCENE_DIRECTORY_STORAGE_FILE = "FILE"; public static final String LUCENE_DIRECTORY_STORAGE_FILE = "FILE";
/**
* Guest user ID.
*/
public static final String GUEST_USER_ID = "guest";
/** /**
* Default generic user role. * Default generic user role.
*/ */

View File

@ -1 +1 @@
db.version=9 db.version=10

View File

@ -0,0 +1,3 @@
insert into T_CONFIG(CFG_ID_C, CFG_VALUE_C) values('GUEST_LOGIN', 'false');
insert into T_USER(USE_ID_C, USE_IDROLE_C, USE_USERNAME_C, USE_PASSWORD_C, USE_EMAIL_C, USE_CREATEDATE_D, USE_PRIVATEKEY_C) values('guest', 'user', 'guest', '', 'guest@localhost', NOW(), 'GuestPk');
update T_CONFIG set CFG_VALUE_C = '10' where CFG_ID_C = 'DB_VERSION';

View File

@ -59,4 +59,9 @@ public class AnonymousPrincipal implements IPrincipal {
public Set<String> getGroupIdSet() { public Set<String> getGroupIdSet() {
return Sets.newHashSet(); return Sets.newHashSet();
} }
@Override
public boolean isGuest() {
return false;
}
} }

View File

@ -18,6 +18,13 @@ public interface IPrincipal extends Principal {
*/ */
boolean isAnonymous(); boolean isAnonymous();
/**
* Checks if the principal is a guest.
*
* @return True if the principal is a guest
*/
boolean isGuest();
/** /**
* Returns the ID of the connected user, or null if the user is anonymous * Returns the ID of the connected user, or null if the user is anonymous
* *

View File

@ -2,6 +2,7 @@ package com.sismics.security;
import java.util.Set; import java.util.Set;
import com.sismics.docs.core.constant.Constants;
import org.joda.time.DateTimeZone; import org.joda.time.DateTimeZone;
/** /**
@ -108,4 +109,9 @@ public class UserPrincipal implements IPrincipal {
public void setGroupIdSet(Set<String> groupIdSet) { public void setGroupIdSet(Set<String> groupIdSet) {
this.groupIdSet = groupIdSet; this.groupIdSet = groupIdSet;
} }
@Override
public boolean isGuest() {
return Constants.GUEST_USER_ID.equals(id);
}
} }

View File

@ -0,0 +1,47 @@
package com.sismics.util.filter;
import com.google.common.base.Strings;
import com.sismics.docs.core.dao.jpa.UserDao;
import com.sismics.docs.core.model.jpa.User;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
/**
* A header-based security filter that authenticates an user using the "X-Authenticated-User" request header as the user ID.
* This filter is intended to be used in conjunction with an external authenticating proxy.
*
* @author pacien
*/
public class HeaderBasedSecurityFilter extends SecurityFilter {
/**
* Authentication header.
*/
public static final String AUTHENTICATED_USER_HEADER = "X-Authenticated-User";
/**
* True if this authentication method is enabled.
*/
private boolean enabled;
@Override
public void init(FilterConfig filterConfig) throws ServletException {
enabled = Boolean.parseBoolean(filterConfig.getInitParameter("enabled"))
|| Boolean.parseBoolean(System.getProperty("docs.header_authentication"));
}
@Override
protected User authenticate(HttpServletRequest request) {
if (!enabled) {
return null;
}
String username = request.getHeader(AUTHENTICATED_USER_HEADER);
if (Strings.isNullOrEmpty(username)) {
return null;
}
return new UserDao().getActiveByUsername(username);
}
}

View File

@ -0,0 +1,145 @@
package com.sismics.util.filter;
import com.sismics.docs.core.constant.Constants;
import com.sismics.docs.core.dao.jpa.GroupDao;
import com.sismics.docs.core.dao.jpa.RoleBaseFunctionDao;
import com.sismics.docs.core.dao.jpa.criteria.GroupCriteria;
import com.sismics.docs.core.dao.jpa.dto.GroupDto;
import com.sismics.docs.core.model.jpa.User;
import com.sismics.security.AnonymousPrincipal;
import com.sismics.security.UserPrincipal;
import jersey.repackaged.com.google.common.collect.Sets;
import org.joda.time.DateTimeZone;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* An abstract security filter for user authentication, that injects corresponding users into the request.
* Successfully authenticated users are injected as UserPrincipal, or as AnonymousPrincipal otherwise.
* If an user has already been authenticated for the request, no further authentication attempt is made.
*
* @author pacien
* @author jtremeaux
*/
public abstract class SecurityFilter implements Filter {
/**
* Name of the attribute containing the principal.
*/
public static final String PRINCIPAL_ATTRIBUTE = "principal";
/**
* Logger.
*/
static final Logger LOG = LoggerFactory.getLogger(SecurityFilter.class);
/**
* Returns true if the supplied request has an UserPrincipal.
*
* @param request HTTP request
* @return True if the supplied request has an UserPrincipal
*/
private boolean hasIdentifiedUser(HttpServletRequest request) {
return request.getAttribute(PRINCIPAL_ATTRIBUTE) instanceof UserPrincipal;
}
/**
* Injects the given user into the request, with the appropriate authentication state.
*
* @param request HTTP request
* @param user nullable User to inject
*/
private void injectUser(HttpServletRequest request, User user) {
// Check if the user is still valid
if (user != null && user.getDeleteDate() == null) {
injectAuthenticatedUser(request, user);
} else {
injectAnonymousUser(request);
}
}
/**
* Inject an authenticated user into the request attributes.
*
* @param request HTTP request
* @param user User to inject
*/
private void injectAuthenticatedUser(HttpServletRequest request, User user) {
UserPrincipal userPrincipal = new UserPrincipal(user.getId(), user.getUsername());
// Add groups
GroupDao groupDao = new GroupDao();
Set<String> groupRoleIdSet = new HashSet<>();
List<GroupDto> groupDtoList = groupDao.findByCriteria(new GroupCriteria()
.setUserId(user.getId())
.setRecursive(true), null);
Set<String> groupIdSet = Sets.newHashSet();
for (GroupDto groupDto : groupDtoList) {
groupIdSet.add(groupDto.getId());
if (groupDto.getRoleId() != null) {
groupRoleIdSet.add(groupDto.getRoleId());
}
}
userPrincipal.setGroupIdSet(groupIdSet);
// Add base functions
groupRoleIdSet.add(user.getRoleId());
RoleBaseFunctionDao userBaseFuction = new RoleBaseFunctionDao();
Set<String> baseFunctionSet = userBaseFuction.findByRoleId(groupRoleIdSet);
userPrincipal.setBaseFunctionSet(baseFunctionSet);
// Add email
userPrincipal.setEmail(user.getEmail());
request.setAttribute(PRINCIPAL_ATTRIBUTE, userPrincipal);
}
/**
* Inject an anonymous user into the request attributes.
*
* @param request HTTP request
*/
private void injectAnonymousUser(HttpServletRequest request) {
AnonymousPrincipal anonymousPrincipal = new AnonymousPrincipal();
anonymousPrincipal.setDateTimeZone(DateTimeZone.forID(Constants.DEFAULT_TIMEZONE_ID));
request.setAttribute(PRINCIPAL_ATTRIBUTE, anonymousPrincipal);
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {
// NOP
}
@Override
public void destroy() {
// NOP
}
@Override
public void doFilter(ServletRequest req, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
if (!hasIdentifiedUser(request)) {
User user = authenticate(request);
injectUser(request, user);
}
filterChain.doFilter(request, response);
}
/**
* Authenticates an user from the given request parameters.
*
* @param request HTTP request
* @return nullable User
*/
protected abstract User authenticate(HttpServletRequest request);
}

View File

@ -1,38 +1,14 @@
package com.sismics.util.filter; package com.sismics.util.filter;
import java.io.IOException;
import java.text.MessageFormat;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import org.joda.time.DateTimeZone;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.sismics.docs.core.constant.Constants;
import com.sismics.docs.core.dao.jpa.AuthenticationTokenDao; import com.sismics.docs.core.dao.jpa.AuthenticationTokenDao;
import com.sismics.docs.core.dao.jpa.GroupDao;
import com.sismics.docs.core.dao.jpa.RoleBaseFunctionDao;
import com.sismics.docs.core.dao.jpa.UserDao; import com.sismics.docs.core.dao.jpa.UserDao;
import com.sismics.docs.core.dao.jpa.criteria.GroupCriteria;
import com.sismics.docs.core.dao.jpa.dto.GroupDto;
import com.sismics.docs.core.model.jpa.AuthenticationToken; import com.sismics.docs.core.model.jpa.AuthenticationToken;
import com.sismics.docs.core.model.jpa.User; import com.sismics.docs.core.model.jpa.User;
import com.sismics.security.AnonymousPrincipal;
import com.sismics.security.UserPrincipal;
import jersey.repackaged.com.google.common.collect.Sets; import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import java.text.MessageFormat;
import java.util.Date;
/** /**
* This filter is used to authenticate the user having an active session via an authentication token stored in database. * This filter is used to authenticate the user having an active session via an authentication token stored in database.
@ -42,22 +18,12 @@ import jersey.repackaged.com.google.common.collect.Sets;
* *
* @author jtremeaux * @author jtremeaux
*/ */
public class TokenBasedSecurityFilter implements Filter { public class TokenBasedSecurityFilter extends SecurityFilter {
/**
* Logger.
*/
private static final Logger log = LoggerFactory.getLogger(TokenBasedSecurityFilter.class);
/** /**
* Name of the cookie used to store the authentication token. * Name of the cookie used to store the authentication token.
*/ */
public static final String COOKIE_NAME = "auth_token"; public static final String COOKIE_NAME = "auth_token";
/**
* Name of the attribute containing the principal.
*/
public static final String PRINCIPAL_ATTRIBUTE = "principal";
/** /**
* Lifetime of the authentication token in seconds, since login. * Lifetime of the authentication token in seconds, since login.
*/ */
@ -66,67 +32,39 @@ public class TokenBasedSecurityFilter implements Filter {
/** /**
* Lifetime of the authentication token in seconds, since last connection. * Lifetime of the authentication token in seconds, since last connection.
*/ */
public static final int TOKEN_SESSION_LIFETIME = 3600 * 24; private static final int TOKEN_SESSION_LIFETIME = 3600 * 24;
@Override /**
public void init(FilterConfig filterConfig) throws ServletException { * Extracts and returns an authentication token from a cookie list.
// NOP *
} * @param cookies Cookie list
* @return nullable auth token
@Override */
public void destroy() { private String extractAuthToken(Cookie[] cookies) {
// NOP if (cookies != null) {
} for (Cookie cookie : cookies) {
if (COOKIE_NAME.equals(cookie.getName()) && !cookie.getValue().isEmpty()) {
@Override return cookie.getValue();
public void doFilter(ServletRequest req, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
// Get the value of the client authentication token
HttpServletRequest request = (HttpServletRequest) req;
String authToken = null;
if (request.getCookies() != null) {
for (Cookie cookie : request.getCookies()) {
if (COOKIE_NAME.equals(cookie.getName())) {
authToken = cookie.getValue();
} }
} }
} }
// Get the corresponding server token return null;
AuthenticationTokenDao authenticationTokenDao = new AuthenticationTokenDao();
AuthenticationToken authenticationToken = null;
if (authToken != null) {
authenticationToken = authenticationTokenDao.get(authToken);
} }
if (authenticationToken == null) { /**
injectAnonymousUser(request); * Deletes an expired authentication token.
} else { *
// Check if the token is still valid * @param authTokenID auth token ID
if (isTokenExpired(authenticationToken)) { */
private void handleExpiredToken(AuthenticationTokenDao dao, String authTokenID) {
try { try {
injectAnonymousUser(request); dao.delete(authTokenID);
// Destroy the expired token
authenticationTokenDao.delete(authToken);
} catch (Exception e) { } catch (Exception e) {
if (log.isErrorEnabled()) { if (LOG.isErrorEnabled())
log.error(MessageFormat.format("Error deleting authentication token {0} ", authToken), e); LOG.error(MessageFormat.format("Error deleting authentication token {0} ", authTokenID), e);
} }
} }
} else {
// Check if the user is still valid
UserDao userDao = new UserDao();
User user = userDao.getById(authenticationToken.getUserId());
if (user != null && user.getDeleteDate() == null) {
injectAuthenticatedUser(request, user);
} else {
injectAnonymousUser(request);
}
}
}
filterChain.doFilter(request, response);
}
/** /**
* Returns true if the token is expired. * Returns true if the token is expired.
@ -146,51 +84,27 @@ public class TokenBasedSecurityFilter implements Filter {
} }
} }
/** @Override
* Inject an authenticated user into the request attributes. protected User authenticate(HttpServletRequest request) {
* // Get the value of the client authentication token
* @param request HTTP request String authTokenId = extractAuthToken(request.getCookies());
* @param user User to inject if (authTokenId == null) {
*/ return null;
private void injectAuthenticatedUser(HttpServletRequest request, User user) {
UserPrincipal userPrincipal = new UserPrincipal(user.getId(), user.getUsername());
// Add groups
GroupDao groupDao = new GroupDao();
Set<String> groupRoleIdSet = new HashSet<>();
List<GroupDto> groupDtoList = groupDao.findByCriteria(new GroupCriteria()
.setUserId(user.getId())
.setRecursive(true), null);
Set<String> groupIdSet = Sets.newHashSet();
for (GroupDto groupDto : groupDtoList) {
groupIdSet.add(groupDto.getId());
if (groupDto.getRoleId() != null) {
groupRoleIdSet.add(groupDto.getRoleId());
}
}
userPrincipal.setGroupIdSet(groupIdSet);
// Add base functions
groupRoleIdSet.add(user.getRoleId());
RoleBaseFunctionDao userBaseFuction = new RoleBaseFunctionDao();
Set<String> baseFunctionSet = userBaseFuction.findByRoleId(groupRoleIdSet);
userPrincipal.setBaseFunctionSet(baseFunctionSet);
// Add email
userPrincipal.setEmail(user.getEmail());
request.setAttribute(PRINCIPAL_ATTRIBUTE, userPrincipal);
} }
/** // Get the corresponding server token
* Inject an anonymous user into the request attributes. AuthenticationTokenDao authTokenDao = new AuthenticationTokenDao();
* AuthenticationToken authToken = authTokenDao.get(authTokenId);
* @param request HTTP request if (authToken == null) {
*/ return null;
private void injectAnonymousUser(HttpServletRequest request) { }
AnonymousPrincipal anonymousPrincipal = new AnonymousPrincipal();
anonymousPrincipal.setDateTimeZone(DateTimeZone.forID(Constants.DEFAULT_TIMEZONE_ID));
request.setAttribute(PRINCIPAL_ATTRIBUTE, anonymousPrincipal); if (isTokenExpired(authToken)) {
handleExpiredToken(authTokenDao, authTokenId);
return null;
}
authTokenDao.updateLastConnectionDate(authToken.getId());
return new UserDao().getById(authToken.getUserId());
} }
} }

View File

@ -5,6 +5,7 @@ import java.net.URI;
import javax.ws.rs.core.Application; import javax.ws.rs.core.Application;
import javax.ws.rs.core.UriBuilder; import javax.ws.rs.core.UriBuilder;
import com.sismics.util.filter.HeaderBasedSecurityFilter;
import org.glassfish.grizzly.http.server.HttpServer; import org.glassfish.grizzly.http.server.HttpServer;
import org.glassfish.grizzly.servlet.ServletRegistration; import org.glassfish.grizzly.servlet.ServletRegistration;
import org.glassfish.grizzly.servlet.WebappContext; import org.glassfish.grizzly.servlet.WebappContext;
@ -62,6 +63,7 @@ public abstract class BaseJerseyTest extends JerseyTest {
@Before @Before
public void setUp() throws Exception { public void setUp() throws Exception {
super.setUp(); super.setUp();
System.setProperty("docs.header_authentication", "true");
clientUtil = new ClientUtil(target()); clientUtil = new ClientUtil(target());
@ -71,6 +73,8 @@ public abstract class BaseJerseyTest extends JerseyTest {
.addMappingForUrlPatterns(null, "/*"); .addMappingForUrlPatterns(null, "/*");
context.addFilter("tokenBasedSecurityFilter", TokenBasedSecurityFilter.class) context.addFilter("tokenBasedSecurityFilter", TokenBasedSecurityFilter.class)
.addMappingForUrlPatterns(null, "/*"); .addMappingForUrlPatterns(null, "/*");
context.addFilter("headerBasedSecurityFilter", HeaderBasedSecurityFilter.class)
.addMappingForUrlPatterns(null, "/*");
ServletRegistration reg = context.addServlet("jerseyServlet", ServletContainer.class); ServletRegistration reg = context.addServlet("jerseyServlet", ServletContainer.class);
reg.setInitParameter("jersey.config.server.provider.packages", "com.sismics.docs.rest.resource"); reg.setInitParameter("jersey.config.server.provider.packages", "com.sismics.docs.rest.resource");
reg.setInitParameter("jersey.config.server.provider.classnames", "org.glassfish.jersey.media.multipart.MultiPartFeature"); reg.setInitParameter("jersey.config.server.provider.classnames", "org.glassfish.jersey.media.multipart.MultiPartFeature");

View File

@ -1,3 +1,3 @@
api.current_version=${project.version} api.current_version=${project.version}
api.min_version=1.0 api.min_version=1.0
db.version=9 db.version=10

View File

@ -14,15 +14,12 @@ import javax.json.JsonArrayBuilder;
import javax.json.JsonObjectBuilder; import javax.json.JsonObjectBuilder;
import javax.persistence.EntityManager; import javax.persistence.EntityManager;
import javax.persistence.Query; import javax.persistence.Query;
import javax.ws.rs.GET; import javax.ws.rs.*;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import com.sismics.docs.core.constant.ConfigType;
import com.sismics.docs.core.constant.PermType; import com.sismics.docs.core.constant.PermType;
import com.sismics.docs.core.dao.jpa.AclDao; import com.sismics.docs.core.dao.jpa.*;
import com.sismics.docs.core.dao.jpa.TagDao;
import com.sismics.docs.core.dao.jpa.criteria.TagCriteria; import com.sismics.docs.core.dao.jpa.criteria.TagCriteria;
import com.sismics.docs.core.dao.jpa.dto.AclDto; import com.sismics.docs.core.dao.jpa.dto.AclDto;
import com.sismics.docs.core.dao.jpa.dto.TagDto; import com.sismics.docs.core.dao.jpa.dto.TagDto;
@ -33,8 +30,6 @@ import org.apache.log4j.Level;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
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.context.AppContext;
import com.sismics.docs.core.model.jpa.File; import com.sismics.docs.core.model.jpa.File;
import com.sismics.docs.core.model.jpa.User; import com.sismics.docs.core.model.jpa.User;
@ -70,33 +65,59 @@ public class AppResource extends BaseResource {
* @apiGroup App * @apiGroup App
* @apiSuccess {String} current_version API current version * @apiSuccess {String} current_version API current version
* @apiSuccess {String} min_version API minimum version * @apiSuccess {String} min_version API minimum version
* @apiSuccess {Boolean} guest_login True if guest login is enabled
* @apiSuccess {String} total_memory Allocated JVM memory (in bytes) * @apiSuccess {String} total_memory Allocated JVM memory (in bytes)
* @apiSuccess {String} free_memory Free JVM memory (in bytes) * @apiSuccess {String} free_memory Free JVM memory (in bytes)
* @apiError (client) ForbiddenError Access denied * @apiPermission none
* @apiPermission user
* @apiVersion 1.5.0 * @apiVersion 1.5.0
* *
* @return Response * @return Response
*/ */
@GET @GET
public Response info() { public Response info() {
if (!authenticate()) {
throw new ForbiddenClientException();
}
ResourceBundle configBundle = ConfigUtil.getConfigBundle(); ResourceBundle configBundle = ConfigUtil.getConfigBundle();
String currentVersion = configBundle.getString("api.current_version"); String currentVersion = configBundle.getString("api.current_version");
String minVersion = configBundle.getString("api.min_version"); String minVersion = configBundle.getString("api.min_version");
Boolean guestLogin = ConfigUtil.getConfigBooleanValue(ConfigType.GUEST_LOGIN);
JsonObjectBuilder response = Json.createObjectBuilder() JsonObjectBuilder response = Json.createObjectBuilder()
.add("current_version", currentVersion.replace("-SNAPSHOT", "")) .add("current_version", currentVersion.replace("-SNAPSHOT", ""))
.add("min_version", minVersion) .add("min_version", minVersion)
.add("guest_login", guestLogin)
.add("total_memory", Runtime.getRuntime().totalMemory()) .add("total_memory", Runtime.getRuntime().totalMemory())
.add("free_memory", Runtime.getRuntime().freeMemory()); .add("free_memory", Runtime.getRuntime().freeMemory());
return Response.ok().entity(response.build()).build(); return Response.ok().entity(response.build()).build();
} }
/**
* Enable/disable guest login.
*
* @api {post} /app/guest_login Enable/disable guest login
* @apiName PostAppGuestLogin
* @apiGroup App
* @apiParam {Boolean} enabled If true, enable guest login
* @apiError (client) ForbiddenError Access denied
* @apiPermission admin
* @apiVersion 1.5.0
*
* @param enabled If true, enable guest login
* @return Response
*/
@POST
@Path("guest_login")
public Response guestLogin(@FormParam("enabled") Boolean enabled) {
if (!authenticate()) {
throw new ForbiddenClientException();
}
checkBaseFunction(BaseFunction.ADMIN);
ConfigDao configDao = new ConfigDao();
configDao.update(ConfigType.GUEST_LOGIN, enabled.toString());
return Response.ok().build();
}
/** /**
* Retrieve the application logs. * Retrieve the application logs.
* *
@ -325,7 +346,7 @@ public class AppResource extends BaseResource {
/** /**
* Recompute the quota for each user. * Recompute the quota for each user.
* *
* @api {post} /app/batch/recompute_quote Recompute user quotas * @api {post} /app/batch/recompute_quota Recompute user quotas
* @apiName PostAppBatchRecomputeQuota * @apiName PostAppBatchRecomputeQuota
* @apiGroup App * @apiGroup App
* @apiSuccess {String} status Status OK * @apiSuccess {String} status Status OK
@ -385,7 +406,7 @@ public class AppResource extends BaseResource {
/** /**
* Add base ACLs to tags. * Add base ACLs to tags.
* *
* @api {post} /app/batch/recompute_quote Add base ACL to tags * @api {post} /app/batch/tag_acls Add base ACL to tags
* @apiDescription This resource must be used after migrating to 1.5. * @apiDescription This resource must be used after migrating to 1.5.
* It will not do anything if base ACL are already present on tags. * It will not do anything if base ACL are already present on tags.
* @apiName PostAppBatchTagAcls * @apiName PostAppBatchTagAcls

View File

@ -1,19 +1,18 @@
package com.sismics.docs.rest.resource; package com.sismics.docs.rest.resource;
import java.security.Principal;
import java.util.List;
import java.util.Set;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Context;
import com.google.common.collect.Lists; import com.google.common.collect.Lists;
import com.sismics.docs.rest.constant.BaseFunction; import com.sismics.docs.rest.constant.BaseFunction;
import com.sismics.rest.exception.ForbiddenClientException; import com.sismics.rest.exception.ForbiddenClientException;
import com.sismics.security.IPrincipal; import com.sismics.security.IPrincipal;
import com.sismics.security.UserPrincipal; import com.sismics.security.UserPrincipal;
import com.sismics.util.filter.TokenBasedSecurityFilter; import com.sismics.util.filter.SecurityFilter;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Context;
import java.security.Principal;
import java.util.List;
import java.util.Set;
/** /**
* Base class of REST resources. * Base class of REST resources.
@ -67,7 +66,7 @@ public abstract class BaseResource {
* @return True if the user is authenticated and not anonymous * @return True if the user is authenticated and not anonymous
*/ */
protected boolean authenticate() { protected boolean authenticate() {
Principal principal = (Principal) request.getAttribute(TokenBasedSecurityFilter.PRINCIPAL_ATTRIBUTE); Principal principal = (Principal) request.getAttribute(SecurityFilter.PRINCIPAL_ATTRIBUTE);
if (principal != null && principal instanceof IPrincipal) { if (principal != null && principal instanceof IPrincipal) {
this.principal = (IPrincipal) principal; this.principal = (IPrincipal) principal;
return !this.principal.isAnonymous(); return !this.principal.isAnonymous();

View File

@ -22,6 +22,8 @@ import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.NewCookie; import javax.ws.rs.core.NewCookie;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import com.sismics.docs.core.constant.ConfigType;
import com.sismics.docs.core.util.ConfigUtil;
import org.apache.commons.lang.StringUtils; import org.apache.commons.lang.StringUtils;
import com.google.common.base.Strings; import com.google.common.base.Strings;
@ -150,7 +152,7 @@ public class UserResource extends BaseResource {
* @apiParam {String{8..50}} password Password * @apiParam {String{8..50}} password Password
* @apiParam {String{1..100}} email E-mail * @apiParam {String{1..100}} email E-mail
* @apiSuccess {String} status Status OK * @apiSuccess {String} status Status OK
* @apiError (client) ForbiddenError Access denied * @apiError (client) ForbiddenError Access denied or connected as guest
* @apiError (client) ValidationError Validation error * @apiError (client) ValidationError Validation error
* @apiPermission user * @apiPermission user
* @apiVersion 1.5.0 * @apiVersion 1.5.0
@ -163,7 +165,7 @@ public class UserResource extends BaseResource {
public Response update( public Response update(
@FormParam("password") String password, @FormParam("password") String password,
@FormParam("email") String email) { @FormParam("email") String email) {
if (!authenticate()) { if (!authenticate() || principal.isGuest()) {
throw new ForbiddenClientException(); throw new ForbiddenClientException();
} }
@ -301,7 +303,7 @@ public class UserResource extends BaseResource {
* @apiName PostUserLogin * @apiName PostUserLogin
* @apiGroup User * @apiGroup User
* @apiParam {String} username Username * @apiParam {String} username Username
* @apiParam {String} password Password * @apiParam {String} password Password (optional for guest login)
* @apiParam {String} code TOTP validation code * @apiParam {String} code TOTP validation code
* @apiParam {Boolean} remember If true, create a long lasted token * @apiParam {Boolean} remember If true, create a long lasted token
* @apiSuccess {String} auth_token A cookie named auth_token containing the token ID * @apiSuccess {String} auth_token A cookie named auth_token containing the token ID
@ -328,7 +330,16 @@ public class UserResource extends BaseResource {
// Get the user // Get the user
UserDao userDao = new UserDao(); UserDao userDao = new UserDao();
User user = userDao.authenticate(username, password); User user = null;
if (Constants.GUEST_USER_ID.equals(username)) {
if (ConfigUtil.getConfigBooleanValue(ConfigType.GUEST_LOGIN)) {
// Login as guest
user = userDao.getActiveByUsername(Constants.GUEST_USER_ID);
}
} else {
// Login as a normal user
user = userDao.authenticate(username, password);
}
if (user == null) { if (user == null) {
throw new ForbiddenClientException(); throw new ForbiddenClientException();
} }
@ -429,7 +440,7 @@ public class UserResource extends BaseResource {
* @apiName DeleteUser * @apiName DeleteUser
* @apiGroup User * @apiGroup User
* @apiSuccess {String} status Status OK * @apiSuccess {String} status Status OK
* @apiError (client) ForbiddenError Access denied or the admin user cannot be deleted * @apiError (client) ForbiddenError Access denied or the user cannot be deleted
* @apiPermission user * @apiPermission user
* @apiVersion 1.5.0 * @apiVersion 1.5.0
* *
@ -442,8 +453,8 @@ public class UserResource extends BaseResource {
} }
// Ensure that the admin user is not deleted // Ensure that the admin user is not deleted
if (hasBaseFunction(BaseFunction.ADMIN)) { if (hasBaseFunction(BaseFunction.ADMIN) || principal.isGuest()) {
throw new ClientException("ForbiddenError", "The admin user cannot be deleted"); throw new ClientException("ForbiddenError", "This user cannot be deleted");
} }
// Find linked data // Find linked data
@ -486,7 +497,7 @@ public class UserResource extends BaseResource {
* @apiName DeleteUserUsername * @apiName DeleteUserUsername
* @apiGroup User * @apiGroup User
* @apiSuccess {String} status Status OK * @apiSuccess {String} status Status OK
* @apiError (client) ForbiddenError Access denied or the admin user cannot be deleted * @apiError (client) ForbiddenError Access denied or the user cannot be deleted
* @apiError (client) UserNotFound The user does not exist * @apiError (client) UserNotFound The user does not exist
* @apiPermission admin * @apiPermission admin
* @apiVersion 1.5.0 * @apiVersion 1.5.0
@ -502,6 +513,11 @@ public class UserResource extends BaseResource {
} }
checkBaseFunction(BaseFunction.ADMIN); checkBaseFunction(BaseFunction.ADMIN);
// Cannot delete the guest user
if (Constants.GUEST_USER_ID.equals(username)) {
throw new ClientException("ForbiddenError", "The guest user cannot be deleted");
}
// Check if the user exists // Check if the user exists
UserDao userDao = new UserDao(); UserDao userDao = new UserDao();
User user = userDao.getActiveByUsername(username); User user = userDao.getActiveByUsername(username);
@ -768,8 +784,10 @@ public class UserResource extends BaseResource {
String authToken = getAuthToken(); String authToken = getAuthToken();
JsonArrayBuilder sessions = Json.createArrayBuilder(); JsonArrayBuilder sessions = Json.createArrayBuilder();
AuthenticationTokenDao authenticationTokenDao = new AuthenticationTokenDao();
// The guest user cannot see other sessions
if (!principal.isGuest()) {
AuthenticationTokenDao authenticationTokenDao = new AuthenticationTokenDao();
for (AuthenticationToken authenticationToken : authenticationTokenDao.getByUserId(principal.getId())) { for (AuthenticationToken authenticationToken : authenticationTokenDao.getByUserId(principal.getId())) {
JsonObjectBuilder session = Json.createObjectBuilder() JsonObjectBuilder session = Json.createObjectBuilder()
.add("create_date", authenticationToken.getCreationDate().getTime()) .add("create_date", authenticationToken.getCreationDate().getTime())
@ -781,6 +799,7 @@ public class UserResource extends BaseResource {
session.add("current", authenticationToken.getId().equals(authToken)); session.add("current", authenticationToken.getId().equals(authToken));
sessions.add(session); sessions.add(session);
} }
}
JsonObjectBuilder response = Json.createObjectBuilder() JsonObjectBuilder response = Json.createObjectBuilder()
.add("sessions", sessions); .add("sessions", sessions);
@ -795,7 +814,7 @@ public class UserResource extends BaseResource {
* @apiName DeleteUserSession * @apiName DeleteUserSession
* @apiGroup User * @apiGroup User
* @apiSuccess {String} status Status OK * @apiSuccess {String} status Status OK
* @apiError (client) ForbiddenError Access denied * @apiError (client) ForbiddenError Access denied or connected as guest
* @apiPermission user * @apiPermission user
* @apiVersion 1.5.0 * @apiVersion 1.5.0
* *
@ -804,7 +823,7 @@ public class UserResource extends BaseResource {
@DELETE @DELETE
@Path("session") @Path("session")
public Response deleteSession() { public Response deleteSession() {
if (!authenticate()) { if (!authenticate() || principal.isGuest()) {
throw new ForbiddenClientException(); throw new ForbiddenClientException();
} }
@ -830,7 +849,7 @@ public class UserResource extends BaseResource {
* @apiName PostUserEnableTotp * @apiName PostUserEnableTotp
* @apiGroup User * @apiGroup User
* @apiSuccess {String} secret Secret TOTP seed to initiate the algorithm * @apiSuccess {String} secret Secret TOTP seed to initiate the algorithm
* @apiError (client) ForbiddenError Access denied * @apiError (client) ForbiddenError Access denied or connected as guest
* @apiPermission user * @apiPermission user
* @apiVersion 1.5.0 * @apiVersion 1.5.0
* *
@ -839,7 +858,7 @@ public class UserResource extends BaseResource {
@POST @POST
@Path("enable_totp") @Path("enable_totp")
public Response enableTotp() { public Response enableTotp() {
if (!authenticate()) { if (!authenticate() || principal.isGuest()) {
throw new ForbiddenClientException(); throw new ForbiddenClientException();
} }
@ -866,7 +885,7 @@ public class UserResource extends BaseResource {
* @apiGroup User * @apiGroup User
* @apiParam {String{1..100}} password Password * @apiParam {String{1..100}} password Password
* @apiSuccess {String} status Status OK * @apiSuccess {String} status Status OK
* @apiError (client) ForbiddenError Access denied * @apiError (client) ForbiddenError Access denied or connected as guest
* @apiError (client) ValidationError Validation error * @apiError (client) ValidationError Validation error
* @apiPermission user * @apiPermission user
* @apiVersion 1.5.0 * @apiVersion 1.5.0
@ -877,7 +896,7 @@ public class UserResource extends BaseResource {
@POST @POST
@Path("disable_totp") @Path("disable_totp")
public Response disableTotp(@FormParam("password") String password) { public Response disableTotp(@FormParam("password") String password) {
if (!authenticate()) { if (!authenticate() || principal.isGuest()) {
throw new ForbiddenClientException(); throw new ForbiddenClientException();
} }

View File

@ -7,12 +7,17 @@ module.exports = function(grunt) {
init: ['dist'], init: ['dist'],
after: ['dist/style.css', 'dist/docs.js', 'dist/share.js', 'dist/less.css', 'dist/app'] after: ['dist/style.css', 'dist/docs.js', 'dist/share.js', 'dist/less.css', 'dist/app']
}, },
ngmin: { ngAnnotate: {
options: {
singleQuotes: true
},
dist: { dist: {
files: [{
expand: true, expand: true,
cwd: 'src', cwd: 'src',
src: ['app/**/*.js'], src: ['app/**/*.js'],
dest: 'dist' dest: 'dist'
}]
} }
}, },
concat: { concat: {
@ -110,12 +115,12 @@ module.exports = function(grunt) {
grunt.loadNpmTasks('grunt-htmlrefs'); grunt.loadNpmTasks('grunt-htmlrefs');
grunt.loadNpmTasks('grunt-css'); grunt.loadNpmTasks('grunt-css');
grunt.loadNpmTasks('grunt-contrib-less'); grunt.loadNpmTasks('grunt-contrib-less');
grunt.loadNpmTasks('grunt-ngmin'); grunt.loadNpmTasks('grunt-ng-annotate');
grunt.loadNpmTasks('grunt-text-replace'); grunt.loadNpmTasks('grunt-text-replace');
grunt.loadNpmTasks('grunt-apidoc'); grunt.loadNpmTasks('grunt-apidoc');
// Default tasks. // Default tasks.
grunt.registerTask('default', ['clean:init', 'ngmin', 'concat:docs', 'concat:share', 'less', 'concat:css', 'cssmin', grunt.registerTask('default', ['clean:init', 'ngAnnotate', 'concat:docs', 'concat:share', 'less', 'concat:css', 'cssmin',
'uglify:docs', 'uglify:share', 'copy', 'clean:after', 'cleanempty', 'htmlrefs:index', 'htmlrefs:share', 'replace', 'apidoc']); 'uglify:docs', 'uglify:share', 'copy', 'clean:after', 'cleanempty', 'htmlrefs:index', 'htmlrefs:share', 'replace', 'apidoc']);
}; };

View File

@ -26,18 +26,33 @@
<url-pattern>*.jsp</url-pattern> <url-pattern>*.jsp</url-pattern>
</filter-mapping> </filter-mapping>
<!-- This filter is used to secure URLs --> <!-- These filters are used to secure URLs -->
<filter> <filter>
<filter-name>tokenBasedSecurityFilter</filter-name> <filter-name>tokenBasedSecurityFilter</filter-name>
<filter-class>com.sismics.util.filter.TokenBasedSecurityFilter</filter-class> <filter-class>com.sismics.util.filter.TokenBasedSecurityFilter</filter-class>
<async-supported>true</async-supported> <async-supported>true</async-supported>
</filter> </filter>
<filter>
<filter-name>headerBasedSecurityFilter</filter-name>
<filter-class>com.sismics.util.filter.HeaderBasedSecurityFilter</filter-class>
<async-supported>true</async-supported>
<init-param>
<param-name>enabled</param-name>
<param-value>false</param-value>
</init-param>
</filter>
<filter-mapping> <filter-mapping>
<filter-name>tokenBasedSecurityFilter</filter-name> <filter-name>tokenBasedSecurityFilter</filter-name>
<url-pattern>/api/*</url-pattern> <url-pattern>/api/*</url-pattern>
</filter-mapping> </filter-mapping>
<filter-mapping>
<filter-name>headerBasedSecurityFilter</filter-name>
<url-pattern>/api/*</url-pattern>
</filter-mapping>
<!-- Jersey --> <!-- Jersey -->
<servlet> <servlet>
<servlet-name>JerseyServlet</servlet-name> <servlet-name>JerseyServlet</servlet-name>

View File

@ -0,0 +1,46 @@
The web client and Android application for **Sismics Docs** are only examples
of what is possible with the provided REST API. Everything you see in those apps are
accessible using the API.
This documentation is divided in two parts. The first will get you started on essentials
steps like authentication and the second part is a full reference of every endpoints.
## API URL
The base URL depends on your server. If your instance of Docs is accessible through
`https://docs.mycompany.com`, then the base API URL is `https://docs.mycompany.com/api`.
## Verbs and status codes
The API uses restful verbs.
| Verb | Description |
|---|---|
| `GET` | Select one or more items |
| `PUT` | Create a new item |
| `POST` | Update an item |
| `DELETE` | Delete an item |
Successful calls return a HTTP code 200, anything else if an error.
## Dates
All dates are returned in UNIX timestamp format in milliseconds.
## Authentication
#### **Step 1: [POST /user/login](#api-User-PostUserLogin)**
A call to this endpoint will return a cookie header like this:
```
HTTP Response:
Set-Cookie: auth_token=64085630-2ae6-415c-9a92-4b22c107eaa4
```
#### **Step 2: Authenticated API calls**
All following API calls must have a cookie header supplying the given token, like this:
```
HTTP Request:
Cookie: auth_token=64085630-2ae6-415c-9a92-4b22c107eaa4
```
#### **Step 3: [POST /user/logout](#api-User-PostUserLogout)**
A call to this API with a given `auth_token` cookie will make it unusable for other calls.

View File

@ -28,7 +28,11 @@
"App", "App",
"Theme", "Theme",
"Vocabulary" "Vocabulary"
] ],
"header": {
"title": "Getting started",
"filename": "header.md"
}
}, },
"devDependencies": { "devDependencies": {
"grunt": "^1.0.1", "grunt": "^1.0.1",
@ -41,7 +45,7 @@
"grunt-contrib-uglify": "^1.0.1", "grunt-contrib-uglify": "^1.0.1",
"grunt-css": "^0.5.4", "grunt-css": "^0.5.4",
"grunt-htmlrefs": "^0.5.0", "grunt-htmlrefs": "^0.5.0",
"grunt-ngmin": "0.0.3", "grunt-ng-annotate": "^2.0.2",
"grunt-text-replace": "^0.4.0", "grunt-text-replace": "^0.4.0",
"protractor": "^3.3.0", "protractor": "^3.3.0",
"selenium": "^2.20.0" "selenium": "^2.20.0"

View File

@ -106,12 +106,12 @@ angular.module('docs',
} }
} }
}) })
.state('settings.theme', { .state('settings.config', {
url: '/theme', url: '/config',
views: { views: {
'settings': { 'settings': {
templateUrl: 'partial/docs/settings.theme.html', templateUrl: 'partial/docs/settings.config.html',
controller: 'SettingsTheme' controller: 'SettingsConfig'
} }
} }
}) })

View File

@ -3,12 +3,24 @@
/** /**
* Login controller. * Login controller.
*/ */
angular.module('docs').controller('Login', function($scope, $rootScope, $state, $dialog, User) { angular.module('docs').controller('Login', function(Restangular, $scope, $rootScope, $state, $dialog, User) {
$scope.codeRequired = false; $scope.codeRequired = false;
/** // Get the app configuration
* Login. Restangular.one('app').get().then(function(data) {
*/ $scope.app = data;
});
// Login as guest
$scope.loginAsGuest = function() {
$scope.user = {
username: 'guest',
password: ''
};
$scope.login();
};
// Login
$scope.login = function() { $scope.login = function() {
User.login($scope.user).then(function() { User.login($scope.user).then(function() {
User.userInfo(true).then(function(data) { User.userInfo(true).then(function(data) {

View File

@ -1,9 +1,23 @@
'use strict'; 'use strict';
/** /**
* Settings theme page controller. * Settings config page controller.
*/ */
angular.module('docs').controller('SettingsTheme', function($scope, $rootScope, Restangular) { angular.module('docs').controller('SettingsConfig', function($scope, $rootScope, Restangular) {
// Get the app configuration
Restangular.one('app').get().then(function(data) {
$scope.app = data;
});
// Enable/disable guest login
$scope.changeGuestLogin = function(enabled) {
Restangular.one('app').post('guest_login', {
enabled: enabled
}).then(function() {
$scope.app.guest_login = enabled;
});
};
// Fetch the current theme configuration // Fetch the current theme configuration
Restangular.one('theme').get().then(function(data) { Restangular.one('theme').get().then(function(data) {
$scope.theme = data; $scope.theme = data;

View File

@ -60,7 +60,7 @@
<script src="app/docs/controller/settings/Settings.js" type="text/javascript"></script> <script src="app/docs/controller/settings/Settings.js" type="text/javascript"></script>
<script src="app/docs/controller/settings/SettingsDefault.js" type="text/javascript"></script> <script src="app/docs/controller/settings/SettingsDefault.js" type="text/javascript"></script>
<script src="app/docs/controller/settings/SettingsAccount.js" type="text/javascript"></script> <script src="app/docs/controller/settings/SettingsAccount.js" type="text/javascript"></script>
<script src="app/docs/controller/settings/SettingsTheme.js" type="text/javascript"></script> <script src="app/docs/controller/settings/SettingsConfig.js" type="text/javascript"></script>
<script src="app/docs/controller/settings/SettingsSecurity.js" type="text/javascript"></script> <script src="app/docs/controller/settings/SettingsSecurity.js" type="text/javascript"></script>
<script src="app/docs/controller/settings/SettingsSecurityModalDisableTotp.js" type="text/javascript"></script> <script src="app/docs/controller/settings/SettingsSecurityModalDisableTotp.js" type="text/javascript"></script>
<script src="app/docs/controller/settings/SettingsSession.js" type="text/javascript"></script> <script src="app/docs/controller/settings/SettingsSession.js" type="text/javascript"></script>
@ -125,12 +125,13 @@
</a> </a>
</li> </li>
<li> <li>
<a href="#/settings/account" title="Logged in as {{ userInfo.username }}"> <a href="{{ userInfo.username == 'guest' ? '#/user/guest' : '#/settings/account' }}"
title="Logged in as {{ userInfo.username }}">
<span class="glyphicon glyphicon-user"></span> <span class="glyphicon glyphicon-user"></span>
{{ userInfo.username }} {{ userInfo.username }}
</a> </a>
</li> </li>
<li ng-class="{active: $uiRoute}" ui-route="/settings.*"> <li ng-class="{active: $uiRoute}" ui-route="/settings.*" ng-show="userInfo.username != 'guest'">
<a href="#/settings/account"> <a href="#/settings/account">
<span class="glyphicon glyphicon-cog"></span> Settings <span class="glyphicon glyphicon-cog"></span> Settings
</a> </a>

View File

@ -46,6 +46,19 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr ng-if="!documents">
<td colspan="3" class="text-center">
<img src="img/loader.gif" />
</td>
</tr>
<tr ng-if="totalDocuments == 0">
<td colspan="3" class="text-center">
<span ng-if="search.length == 0">No document in the database</span>
<span ng-if="search.length > 0">No matches for <strong>"{{ search }}"</strong></span>
</td>
</tr>
<tr ng-click="viewDocument(document.id)" ng-repeat="document in documents" ng-class="{ active: $stateParams.id == document.id }"> <tr ng-click="viewDocument(document.id)" ng-repeat="document in documents" ng-class="{ active: $stateParams.id == document.id }">
<td> <td>
{{ document.title }} ({{ document.file_count }}) {{ document.title }} ({{ document.file_count }})
@ -79,8 +92,11 @@
used on {{ userInfo.storage_quota / 1000000 | number: 0 }}MB used on {{ userInfo.storage_quota / 1000000 | number: 0 }}MB
</div> </div>
<div class="text-right"> <div class="text-right" >
<span ng-if="totalDocuments">
{{ totalDocuments }} document{{ totalDocuments > 1 ? 's' : '' }} found {{ totalDocuments }} document{{ totalDocuments > 1 ? 's' : '' }} found
</span>
<span ng-if="!totalDocuments">&nbsp;</span>
</div> </div>
</div> </div>
</div> </div>

View File

@ -35,6 +35,12 @@
<button type="submit" class="btn btn-primary btn-block" ng-click="login()"> <button type="submit" class="btn btn-primary btn-block" ng-click="login()">
<span class="glyphicon glyphicon-ok"></span> Sign in <span class="glyphicon glyphicon-ok"></span> Sign in
</button> </button>
<p class="text-center lead" ng-if="app.guest_login">&nbsp;</p>
<button type="submit" class="btn btn-default btn-block" ng-if="app.guest_login" ng-click="loginAsGuest()">
<span class="glyphicon glyphicon-user"></span> Login as guest
</button>
</form> </form>
</div> </div>
</div> </div>

View File

@ -1,3 +1,18 @@
<h1>
Guest <small>access</small>
<span class="label" ng-class="{ 'label-success': app.guest_login, 'label-danger': !app.guest_login }">
{{ app.guest_login ? 'Enabled' : 'Disabled' }}
</span>
</h1>
<p>
Guest access is a mode where anyone can access {{ appName }} without password.<br/>
Like a normal user, the guest user can only access its documents and those accessible through permissions.<br/>
</p>
<div ng-if="app">
<button ng-if="!app.guest_login" class="btn btn-primary" ng-click="changeGuestLogin(true)">Enable guest access</button>
<button ng-if="app.guest_login" class="btn btn-danger" ng-click="changeGuestLogin(false)">Disable guest access</button>
</div>
<h1>Theme <small>customization</small></h1> <h1>Theme <small>customization</small></h1>
<form class="form-horizontal" name="editColorForm" novalidate> <form class="form-horizontal" name="editColorForm" novalidate>
<div class="form-group"> <div class="form-group">

View File

@ -1,4 +1,4 @@
<h1>Groups <small>management</small> <a class="btn btn-primary" href="#/settings/group/add">Add</a></h1> <h1>Groups <small>management</small> <a class="btn btn-primary" href="#/settings/group/add">Add a group</a></h1>
<div class="row"> <div class="row">
<div class="col-md-4 well"> <div class="col-md-4 well">

View File

@ -9,14 +9,14 @@
</ul> </ul>
</div> </div>
<div class="panel panel-default"> <div class="panel panel-default" ng-show="isAdmin">
<div class="panel-heading" ng-show="isAdmin"><strong>General settings</strong></div> <div class="panel-heading"><strong>General settings</strong></div>
<ul class="list-group"> <ul class="list-group">
<a class="list-group-item" ng-show="isAdmin" ng-class="{active: $uiRoute}" ui-route="/settings/user.*" href="#/settings/user">Users</a> <a class="list-group-item" ng-class="{active: $uiRoute}" ui-route="/settings/user.*" href="#/settings/user">Users</a>
<a class="list-group-item" ng-show="isAdmin" ng-class="{active: $uiRoute}" ui-route="/settings/group.*" href="#/settings/group">Groups</a> <a class="list-group-item" ng-class="{active: $uiRoute}" ui-route="/settings/group.*" href="#/settings/group">Groups</a>
<a class="list-group-item" ng-show="isAdmin" ng-class="{active: $uiRoute}" ui-route="/settings/vocabulary.*" href="#/settings/vocabulary">Vocabularies</a> <a class="list-group-item" ng-class="{active: $uiRoute}" ui-route="/settings/vocabulary.*" href="#/settings/vocabulary">Vocabularies</a>
<a class="list-group-item" ng-show="isAdmin" ng-class="{active: $uiRoute}" ui-route="/settings/theme" href="#/settings/theme">Theme</a> <a class="list-group-item" ng-class="{active: $uiRoute}" ui-route="/settings/config" href="#/settings/config">Configuration</a>
<a class="list-group-item" ng-show="isAdmin" ng-class="{active: $uiRoute}" ui-route="/settings/log" href="#/settings/log">Server logs</a> <a class="list-group-item" ng-class="{active: $uiRoute}" ui-route="/settings/log" href="#/settings/log">Server logs</a>
</ul> </ul>
</div> </div>
</div> </div>

View File

@ -13,10 +13,12 @@
<td>{{ session.create_date | date: 'yyyy-MM-dd HH:mm' }}</td> <td>{{ session.create_date | date: 'yyyy-MM-dd HH:mm' }}</td>
<td>{{ session.last_connection_date | date: 'yyyy-MM-dd HH:mm' }}</td> <td>{{ session.last_connection_date | date: 'yyyy-MM-dd HH:mm' }}</td>
<td title="{{ session.user_agent }}">{{ session.ip }}</td> <td title="{{ session.user_agent }}">{{ session.ip }}</td>
<td><span ng-show="session.current" class="glyphicon glyphicon-ok"></span></td> <td>
<span ng-show="session.current" class="glyphicon glyphicon-ok" title="This is the current session"></span>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-warning" ng-click="deleteSession()">Clear all other sessions</button> <button type="submit" class="btn btn-warning" title="All other devices connected to this account will be disconnected" ng-click="deleteSession()">Clear all other sessions</button>
</div> </div>

View File

@ -62,7 +62,7 @@
<span class="help-block" ng-show="editUserForm.storage_quota.$error.pattern">Number required</span> <span class="help-block" ng-show="editUserForm.storage_quota.$error.pattern">Number required</span>
</div> </div>
</div> </div>
<div class="form-group" ng-class="{ 'has-error': !editUserForm.password.$valid, success: editUserForm.password.$valid }"> <div class="form-group" ng-if="user.username != 'guest'" ng-class="{ 'has-error': !editUserForm.password.$valid, success: editUserForm.password.$valid }">
<label class="col-sm-2 control-label" for="inputPassword">Password</label> <label class="col-sm-2 control-label" for="inputPassword">Password</label>
<div class="col-sm-7"> <div class="col-sm-7">
@ -76,7 +76,7 @@
<span class="help-block" ng-show="editUserForm.password.$error.maxlength">Too long</span> <span class="help-block" ng-show="editUserForm.password.$error.maxlength">Too long</span>
</div> </div>
</div> </div>
<div class="form-group" ng-class="{ 'has-error': !editUserForm.passwordconfirm.$valid, success: editUserForm.passwordconfirm.$valid }"> <div class="form-group" ng-if="user.username != 'guest'" ng-class="{ 'has-error': !editUserForm.passwordconfirm.$valid, success: editUserForm.passwordconfirm.$valid }">
<label class="col-sm-2 -label" for="inputPasswordConfirm">Password (confirm)</label> <label class="col-sm-2 -label" for="inputPasswordConfirm">Password (confirm)</label>
<div class="col-sm-7"> <div class="col-sm-7">
@ -94,7 +94,7 @@
<button type="submit" class="btn btn-primary" ng-click="edit()" ng-disabled="!editUserForm.$valid"> <button type="submit" class="btn btn-primary" ng-click="edit()" ng-disabled="!editUserForm.$valid">
<span class="glyphicon glyphicon-pencil"></span> {{ isEdit() ? 'Edit' : 'Add' }} <span class="glyphicon glyphicon-pencil"></span> {{ isEdit() ? 'Edit' : 'Add' }}
</button> </button>
<button type="button" class="btn btn-danger" ng-click="remove()" ng-show="isEdit()"> <button type="button" class="btn btn-danger" ng-click="remove()" ng-show="isEdit() && user.username != 'guest'">
<span class="glyphicon glyphicon-trash"></span> Delete <span class="glyphicon glyphicon-trash"></span> Delete
</button> </button>
</div> </div>

View File

@ -1,4 +1,4 @@
<h1>Users <small>management</small> <a class="btn btn-primary" href="#/settings/user/add">Add</a></h1> <h1>Users <small>management</small> <a class="btn btn-primary" href="#/settings/user/add">Add a user</a></h1>
<div class="row"> <div class="row">
<div class="col-md-4 well"> <div class="col-md-4 well">

View File

@ -1,3 +1,3 @@
api.current_version=${project.version} api.current_version=${project.version}
api.min_version=1.0 api.min_version=1.0
db.version=9 db.version=10

View File

@ -1,3 +1,3 @@
api.current_version=${project.version} api.current_version=${project.version}
api.min_version=1.0 api.min_version=1.0
db.version=9 db.version=10

View File

@ -1,5 +1,13 @@
package com.sismics.docs.rest; package com.sismics.docs.rest;
import com.sismics.docs.core.constant.PermType;
import com.sismics.docs.core.dao.jpa.AclDao;
import com.sismics.util.context.ThreadLocalContext;
import com.sismics.util.filter.TokenBasedSecurityFilter;
import com.sismics.util.jpa.EMF;
import org.junit.Assert;
import org.junit.Test;
import javax.json.JsonArray; import javax.json.JsonArray;
import javax.json.JsonObject; import javax.json.JsonObject;
import javax.persistence.EntityManager; import javax.persistence.EntityManager;
@ -9,15 +17,6 @@ import javax.ws.rs.core.Form;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status; import javax.ws.rs.core.Response.Status;
import com.sismics.docs.core.constant.PermType;
import com.sismics.docs.core.dao.jpa.AclDao;
import com.sismics.util.context.ThreadLocalContext;
import com.sismics.util.jpa.EMF;
import org.junit.Assert;
import org.junit.Test;
import com.sismics.util.filter.TokenBasedSecurityFilter;
/** /**
* Test the app resource. * Test the app resource.
@ -35,16 +34,14 @@ public class TestAppResource extends BaseJerseyTest {
// Check the application info // Check the application info
JsonObject json = target().path("/app").request() JsonObject json = target().path("/app").request()
.cookie(TokenBasedSecurityFilter.COOKIE_NAME, adminToken)
.get(JsonObject.class); .get(JsonObject.class);
String currentVersion = json.getString("current_version"); Assert.assertNotNull(json.getString("current_version"));
Assert.assertNotNull(currentVersion); Assert.assertNotNull(json.getString("min_version"));
String minVersion = json.getString("min_version");
Assert.assertNotNull(minVersion);
Long freeMemory = json.getJsonNumber("free_memory").longValue(); Long freeMemory = json.getJsonNumber("free_memory").longValue();
Assert.assertTrue(freeMemory > 0); Assert.assertTrue(freeMemory > 0);
Long totalMemory = json.getJsonNumber("total_memory").longValue(); Long totalMemory = json.getJsonNumber("total_memory").longValue();
Assert.assertTrue(totalMemory > 0 && totalMemory > freeMemory); Assert.assertTrue(totalMemory > 0 && totalMemory > freeMemory);
Assert.assertFalse(json.getBoolean("guest_login"));
// Rebuild Lucene index // Rebuild Lucene index
Response response = target().path("/app/batch/reindex").request() Response response = target().path("/app/batch/reindex").request()
@ -127,4 +124,69 @@ public class TestAppResource extends BaseJerseyTest {
Long date4 = logs.getJsonObject(9).getJsonNumber("date").longValue(); Long date4 = logs.getJsonObject(9).getJsonNumber("date").longValue();
Assert.assertTrue(date3 >= date4); Assert.assertTrue(date3 >= date4);
} }
/**
* Test the guest login.
*/
@Test
public void testGuestLogin() {
// Login admin
String adminToken = clientUtil.login("admin", "admin", false);
// Try to login as guest
Response response = target().path("/user/login").request()
.post(Entity.form(new Form()
.param("username", "guest")));
Assert.assertEquals(Status.FORBIDDEN.getStatusCode(), response.getStatus());
// Enable guest login
target().path("/app/guest_login").request()
.cookie(TokenBasedSecurityFilter.COOKIE_NAME, adminToken)
.post(Entity.form(new Form()
.param("enabled", "true")), JsonObject.class);
// Login as guest
String guestToken = clientUtil.login("guest", "", false);
// Guest cannot delete himself
response = target().path("/user").request()
.cookie(TokenBasedSecurityFilter.COOKIE_NAME, guestToken)
.delete();
Assert.assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
// Guest cannot see opened sessions
JsonObject json = target().path("/user/session").request()
.cookie(TokenBasedSecurityFilter.COOKIE_NAME, guestToken)
.get(JsonObject.class);
Assert.assertEquals(0, json.getJsonArray("sessions").size());
// Guest cannot delete opened sessions
response = target().path("/user/session").request()
.cookie(TokenBasedSecurityFilter.COOKIE_NAME, guestToken)
.delete();
Assert.assertEquals(Status.FORBIDDEN.getStatusCode(), response.getStatus());
// Guest cannot enable TOTP
response = target().path("/user/enable_totp").request()
.cookie(TokenBasedSecurityFilter.COOKIE_NAME, guestToken)
.post(Entity.form(new Form()));
Assert.assertEquals(Status.FORBIDDEN.getStatusCode(), response.getStatus());
// Guest cannot disable TOTP
response = target().path("/user/disable_totp").request()
.cookie(TokenBasedSecurityFilter.COOKIE_NAME, guestToken)
.post(Entity.form(new Form()));
Assert.assertEquals(Status.FORBIDDEN.getStatusCode(), response.getStatus());
// Guest cannot update itself
response = target().path("/user").request()
.cookie(TokenBasedSecurityFilter.COOKIE_NAME, guestToken)
.post(Entity.form(new Form()));
Assert.assertEquals(Status.FORBIDDEN.getStatusCode(), response.getStatus());
// Guest can see its documents
target().path("/document/list").request()
.cookie(TokenBasedSecurityFilter.COOKIE_NAME, guestToken)
.get(JsonObject.class);
}
} }

View File

@ -6,6 +6,7 @@ import javax.ws.rs.core.Form;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status; import javax.ws.rs.core.Response.Status;
import com.sismics.util.filter.HeaderBasedSecurityFilter;
import org.junit.Assert; import org.junit.Assert;
import org.apache.commons.lang.StringUtils; import org.apache.commons.lang.StringUtils;
@ -28,7 +29,7 @@ public class TestSecurity extends BaseJerseyTest {
clientUtil.createUser("testsecurity"); clientUtil.createUser("testsecurity");
// Changes a user's email KO : the user is not connected // Changes a user's email KO : the user is not connected
Response response = target().path("/user/update").request() Response response = target().path("/user").request()
.post(Entity.form(new Form().param("email", "testsecurity2@docs.com"))); .post(Entity.form(new Form().param("email", "testsecurity2@docs.com")));
Assert.assertEquals(Status.FORBIDDEN, Status.fromStatusCode(response.getStatus())); Assert.assertEquals(Status.FORBIDDEN, Status.fromStatusCode(response.getStatus()));
JsonObject json = response.readEntity(JsonObject.class); JsonObject json = response.readEntity(JsonObject.class);
@ -73,4 +74,29 @@ public class TestSecurity extends BaseJerseyTest {
// User testsecurity logs out // User testsecurity logs out
clientUtil.logout(testSecurityToken); clientUtil.logout(testSecurityToken);
} }
@Test
public void testHeaderBasedAuthentication() {
clientUtil.createUser("header_auth_test");
Assert.assertEquals(Status.FORBIDDEN.getStatusCode(), target()
.path("/user/session")
.request()
.get()
.getStatus());
Assert.assertEquals(Status.OK.getStatusCode(), target()
.path("/user/session")
.request()
.header(HeaderBasedSecurityFilter.AUTHENTICATED_USER_HEADER, "header_auth_test")
.get()
.getStatus());
Assert.assertEquals(Status.FORBIDDEN.getStatusCode(), target()
.path("/user/session")
.request()
.header(HeaderBasedSecurityFilter.AUTHENTICATED_USER_HEADER, "idontexist")
.get()
.getStatus());
}
} }

View File

@ -4,7 +4,7 @@ hibernate.connection.username=sa
hibernate.connection.password= hibernate.connection.password=
hibernate.hbm2ddl.auto= hibernate.hbm2ddl.auto=
hibernate.dialect=org.hibernate.dialect.HSQLDialect hibernate.dialect=org.hibernate.dialect.HSQLDialect
hibernate.show_sql=true hibernate.show_sql=false
hibernate.format_sql=false hibernate.format_sql=false
hibernate.max_fetch_depth=5 hibernate.max_fetch_depth=5
hibernate.cache.use_second_level_cache=false hibernate.cache.use_second_level_cache=false

View File

@ -1,10 +1,10 @@
log4j.rootCategory=DEBUG, CONSOLE, MEMORY log4j.rootCategory=INFO, CONSOLE, MEMORY
log4j.appender.CONSOLE=org.apache.log4j.ConsoleAppender log4j.appender.CONSOLE=org.apache.log4j.ConsoleAppender
log4j.appender.CONSOLE.layout=org.apache.log4j.PatternLayout log4j.appender.CONSOLE.layout=org.apache.log4j.PatternLayout
log4j.appender.CONSOLE.layout.ConversionPattern=%d{DATE} %p %l %m %n log4j.appender.CONSOLE.layout.ConversionPattern=%d{DATE} %p %l %m %n
log4j.appender.MEMORY=com.sismics.util.log4j.MemoryAppender log4j.appender.MEMORY=com.sismics.util.log4j.MemoryAppender
log4j.appender.MEMORY.size=1000 log4j.appender.MEMORY.size=1000
log4j.logger.com.sismics=DEBUG log4j.logger.com.sismics=INFO
log4j.logger.org.hibernate=INFO log4j.logger.org.hibernate=INFO
log4j.logger.org.apache.pdfbox=INFO log4j.logger.org.apache.pdfbox=INFO