Merge branch 'develop' of https://github.com/vivo-project/Vitro into develop
This commit is contained in:
commit
e822dfb3dc
12 changed files with 239 additions and 128 deletions
|
@ -16,7 +16,7 @@
|
|||
<auth:status rdf:datatype="http://www.w3.org/2001/XMLSchema#string">ACTIVE</auth:status>
|
||||
<auth:loginCount rdf:datatype="http://www.w3.org/2001/XMLSchema#int">1</auth:loginCount>
|
||||
<auth:passwordLinkExpires rdf:datatype="http://www.w3.org/2001/XMLSchema#long">0</auth:passwordLinkExpires>
|
||||
<auth:hasPermissionSet rdf:resource="http://permissionSet-50"/>
|
||||
<auth:hasPermissionSet rdf:resource="http://vitro.mannlib.cornell.edu/ns/vitro/authorization#ADMIN"/>
|
||||
</auth:UserAccount>
|
||||
|
||||
<auth:UserAccount rdf:about="http://vivo.mydomain.edu/individual/JohnCurator">
|
||||
|
@ -28,7 +28,7 @@
|
|||
<auth:status rdf:datatype="http://www.w3.org/2001/XMLSchema#string">ACTIVE</auth:status>
|
||||
<auth:loginCount rdf:datatype="http://www.w3.org/2001/XMLSchema#int">1</auth:loginCount>
|
||||
<auth:passwordLinkExpires rdf:datatype="http://www.w3.org/2001/XMLSchema#long">0</auth:passwordLinkExpires>
|
||||
<auth:hasPermissionSet rdf:resource="http://permissionSet-5"/>
|
||||
<auth:hasPermissionSet rdf:resource="http://vitro.mannlib.cornell.edu/ns/vitro/authorization#CURATOR"/>
|
||||
</auth:UserAccount>
|
||||
|
||||
<auth:UserAccount rdf:about="http://vivo.mydomain.edu/individual/SallyEditor">
|
||||
|
@ -40,7 +40,7 @@
|
|||
<auth:status rdf:datatype="http://www.w3.org/2001/XMLSchema#string">ACTIVE</auth:status>
|
||||
<auth:loginCount rdf:datatype="http://www.w3.org/2001/XMLSchema#int">1</auth:loginCount>
|
||||
<auth:passwordLinkExpires rdf:datatype="http://www.w3.org/2001/XMLSchema#long">0</auth:passwordLinkExpires>
|
||||
<auth:hasPermissionSet rdf:resource="http://permissionSet-4"/>
|
||||
<auth:hasPermissionSet rdf:resource="http://vitro.mannlib.cornell.edu/ns/vitro/authorization#EDITOR"/>
|
||||
</auth:UserAccount>
|
||||
|
||||
<auth:UserAccount rdf:about="http://vivo.mydomain.edu/individual/JoeUser">
|
||||
|
@ -52,7 +52,7 @@
|
|||
<auth:status rdf:datatype="http://www.w3.org/2001/XMLSchema#string">ACTIVE</auth:status>
|
||||
<auth:loginCount rdf:datatype="http://www.w3.org/2001/XMLSchema#int">1</auth:loginCount>
|
||||
<auth:passwordLinkExpires rdf:datatype="http://www.w3.org/2001/XMLSchema#long">0</auth:passwordLinkExpires>
|
||||
<auth:hasPermissionSet rdf:resource="http://permissionSet-1"/>
|
||||
<auth:hasPermissionSet rdf:resource="http://vitro.mannlib.cornell.edu/ns/vitro/authorization#SELF_EDITOR"/>
|
||||
</auth:UserAccount>
|
||||
|
||||
</rdf:RDF>
|
||||
|
|
|
@ -12,6 +12,7 @@ auth:ADMIN
|
|||
|
||||
# ADMIN-only permissions
|
||||
auth:hasPermission simplePermission:AccessSpecialDataModels ;
|
||||
auth:hasPermission simplePermission:EnableDeveloperPanel ;
|
||||
auth:hasPermission simplePermission:LoginDuringMaintenance ;
|
||||
auth:hasPermission simplePermission:ManageMenus ;
|
||||
auth:hasPermission simplePermission:ManageProxies ;
|
||||
|
@ -24,7 +25,11 @@ auth:ADMIN
|
|||
auth:hasPermission simplePermission:UseMiscellaneousAdminPages ;
|
||||
auth:hasPermission simplePermission:UseSparqlQueryPage ;
|
||||
auth:hasPermission simplePermission:PageViewableAdmin ;
|
||||
auth:hasPermission simplePermission:EnableDeveloperPanel ;
|
||||
|
||||
# Uncomment the following permission line to enable the SPARQL update API.
|
||||
# Before enabling, be sure that the URL api/sparqlUpdate is secured by SSH,
|
||||
# so passwords will not be sent in clear text.
|
||||
# auth:hasPermission simplePermission:UseSparqlUpdateApi ;
|
||||
|
||||
# permissions for CURATOR and above.
|
||||
auth:hasPermission simplePermission:EditOntology ;
|
||||
|
|
|
@ -38,6 +38,8 @@ public class SimplePermission extends Permission {
|
|||
NAMESPACE + "EditOwnAccount");
|
||||
public static final SimplePermission EDIT_SITE_INFORMATION = new SimplePermission(
|
||||
NAMESPACE + "EditSiteInformation");
|
||||
public static final SimplePermission ENABLE_DEVELOPER_PANEL = new SimplePermission(
|
||||
NAMESPACE + "EnableDeveloperPanel");
|
||||
public static final SimplePermission LOGIN_DURING_MAINTENANCE = new SimplePermission(
|
||||
NAMESPACE + "LoginDuringMaintenance");
|
||||
public static final SimplePermission MANAGE_MENUS = new SimplePermission(
|
||||
|
@ -76,9 +78,8 @@ public class SimplePermission extends Permission {
|
|||
NAMESPACE + "UseAdvancedDataToolsPages");
|
||||
public static final SimplePermission USE_SPARQL_QUERY_PAGE = new SimplePermission(
|
||||
NAMESPACE + "UseSparqlQueryPage");
|
||||
public static final SimplePermission ENABLE_DEVELOPER_PANEL = new SimplePermission(
|
||||
NAMESPACE + "EnableDeveloperPanel");
|
||||
|
||||
public static final SimplePermission USE_SPARQL_UPDATE_API = new SimplePermission(
|
||||
NAMESPACE + "UseSparqlUpdateApi");
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// These instances are "catch all" permissions to cover poorly defined
|
||||
|
|
|
@ -83,11 +83,11 @@ public class PolicyHelper {
|
|||
}
|
||||
|
||||
try{
|
||||
Authenticator basicAuth = new BasicAuthenticator(req);
|
||||
UserAccount user = basicAuth.getAccountForInternalAuth( email );
|
||||
Authenticator auth = Authenticator.getInstance(req);
|
||||
UserAccount user = auth.getAccountForInternalAuth( email );
|
||||
log.debug("userAccount is " + user==null?"null":user.getUri() );
|
||||
|
||||
if( ! basicAuth.isCurrentPassword( user, password ) ){
|
||||
if( ! auth.isCurrentPassword( user, password ) ){
|
||||
log.debug(String.format("UNAUTHORIZED, password not accepted for %s, account URI: %s",
|
||||
user.getEmailAddress(), user.getUri()));
|
||||
return false;
|
||||
|
|
|
@ -0,0 +1,201 @@
|
|||
/* $This file is distributed under the terms of the license in /doc/license.txt$ */
|
||||
|
||||
package edu.cornell.mannlib.vitro.webapp.controller.api;
|
||||
|
||||
import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
|
||||
import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
|
||||
import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
|
||||
import static javax.servlet.http.HttpServletResponse.SC_OK;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
|
||||
import javax.servlet.ServletContext;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.http.HttpServlet;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
import org.apache.commons.lang.StringUtils;
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
|
||||
import com.hp.hpl.jena.query.Dataset;
|
||||
import com.hp.hpl.jena.update.GraphStore;
|
||||
import com.hp.hpl.jena.update.GraphStoreFactory;
|
||||
import com.hp.hpl.jena.update.UpdateAction;
|
||||
import com.hp.hpl.jena.update.UpdateFactory;
|
||||
import com.hp.hpl.jena.update.UpdateRequest;
|
||||
|
||||
import edu.cornell.mannlib.vitro.webapp.auth.permissions.SimplePermission;
|
||||
import edu.cornell.mannlib.vitro.webapp.auth.policy.PolicyHelper;
|
||||
import edu.cornell.mannlib.vitro.webapp.auth.requestedAction.Actions;
|
||||
import edu.cornell.mannlib.vitro.webapp.beans.UserAccount;
|
||||
import edu.cornell.mannlib.vitro.webapp.controller.VitroRequest;
|
||||
import edu.cornell.mannlib.vitro.webapp.controller.authenticate.Authenticator;
|
||||
import edu.cornell.mannlib.vitro.webapp.dao.jena.RDFServiceDataset;
|
||||
import edu.cornell.mannlib.vitro.webapp.search.indexing.IndexBuilder;
|
||||
|
||||
/**
|
||||
* This extends HttpServlet instead of VitroHttpServlet because we want to have
|
||||
* full control over the response:
|
||||
* <ul>
|
||||
* <li>No redirecting to the login page if not authorized</li>
|
||||
* <li>No redirecting to the home page on insufficient authorization</li>
|
||||
* <li>No support for GET or HEAD requests, only POST.</li>
|
||||
* </ul>
|
||||
*
|
||||
* So these responses will be produced:
|
||||
*
|
||||
* <pre>
|
||||
* 200 Success
|
||||
* 400 Failed to parse SPARQL update
|
||||
* 400 SPARQL update must specify a GRAPH URI.
|
||||
* 403 username/password combination is not valid
|
||||
* 403 Account is not authorized
|
||||
* 405 Method not allowed
|
||||
* 500 Unknown error
|
||||
* </pre>
|
||||
*/
|
||||
public class SparqlUpdateApiController extends HttpServlet {
|
||||
private static final Log log = LogFactory
|
||||
.getLog(SparqlUpdateApiController.class);
|
||||
|
||||
private static final Actions REQUIRED_ACTIONS = SimplePermission.USE_SPARQL_UPDATE_API.ACTIONS;
|
||||
|
||||
@Override
|
||||
protected void doPost(HttpServletRequest req, HttpServletResponse resp)
|
||||
throws ServletException, IOException {
|
||||
try {
|
||||
checkAuthorization(req);
|
||||
UpdateRequest parsed = parseUpdateString(req);
|
||||
executeUpdate(req, parsed);
|
||||
do200response(resp);
|
||||
} catch (AuthException e) {
|
||||
do403response(resp, e);
|
||||
} catch (ParseException e) {
|
||||
do400response(resp, e);
|
||||
} catch (Exception e) {
|
||||
do500response(resp, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void checkAuthorization(HttpServletRequest req)
|
||||
throws AuthException {
|
||||
String email = req.getParameter("email");
|
||||
String password = req.getParameter("password");
|
||||
|
||||
Authenticator auth = Authenticator.getInstance(req);
|
||||
UserAccount account = auth.getAccountForInternalAuth(email);
|
||||
if (!auth.isCurrentPassword(account, password)) {
|
||||
log.debug("Invalid: '" + email + "'/'" + password + "'");
|
||||
throw new AuthException("email/password combination is not valid");
|
||||
}
|
||||
|
||||
if (!PolicyHelper.isAuthorizedForActions(req, email, password,
|
||||
REQUIRED_ACTIONS)) {
|
||||
log.debug("Not authorized: '" + email + "'");
|
||||
throw new AuthException("Account is not authorized");
|
||||
}
|
||||
|
||||
log.debug("Authorized for '" + email + "'");
|
||||
}
|
||||
|
||||
private UpdateRequest parseUpdateString(HttpServletRequest req)
|
||||
throws ParseException {
|
||||
String update = req.getParameter("update");
|
||||
if (StringUtils.isBlank(update)) {
|
||||
log.debug("No update parameter.");
|
||||
throw new ParseException("No 'update' parameter.");
|
||||
}
|
||||
|
||||
if (!StringUtils.containsIgnoreCase(update, "GRAPH")) {
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("No GRAPH uri in '" + update + "'");
|
||||
}
|
||||
throw new ParseException("SPARQL update must specify a GRAPH URI.");
|
||||
}
|
||||
|
||||
try {
|
||||
return UpdateFactory.create(update);
|
||||
} catch (Exception e) {
|
||||
log.debug("Problem parsing", e);
|
||||
throw new ParseException("Failed to parse SPARQL update", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void executeUpdate(HttpServletRequest req, UpdateRequest parsed) {
|
||||
ServletContext ctx = req.getSession().getServletContext();
|
||||
VitroRequest vreq = new VitroRequest(req);
|
||||
|
||||
IndexBuilder.getBuilder(ctx).pause();
|
||||
try {
|
||||
Dataset ds = new RDFServiceDataset(vreq.getUnfilteredRDFService());
|
||||
GraphStore graphStore = GraphStoreFactory.create(ds);
|
||||
UpdateAction.execute(parsed, graphStore);
|
||||
} finally {
|
||||
IndexBuilder.getBuilder(ctx).unpause();
|
||||
}
|
||||
}
|
||||
|
||||
private void do200response(HttpServletResponse resp) throws IOException {
|
||||
doResponse(resp, SC_OK, "SPARQL update accepted.");
|
||||
}
|
||||
|
||||
private void do403response(HttpServletResponse resp, AuthException e)
|
||||
throws IOException {
|
||||
doResponse(resp, SC_FORBIDDEN, e.getMessage());
|
||||
}
|
||||
|
||||
private void do400response(HttpServletResponse resp, ParseException e)
|
||||
throws IOException {
|
||||
if (e.getCause() == null) {
|
||||
doResponse(resp, SC_BAD_REQUEST, e.getMessage());
|
||||
} else {
|
||||
doResponse(resp, SC_BAD_REQUEST, e.getMessage(), e.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
private void do500response(HttpServletResponse resp, Exception e)
|
||||
throws IOException {
|
||||
doResponse(resp, SC_INTERNAL_SERVER_ERROR, "Unknown error", e);
|
||||
}
|
||||
|
||||
private void doResponse(HttpServletResponse resp, int statusCode,
|
||||
String message) throws IOException {
|
||||
resp.setStatus(statusCode);
|
||||
PrintWriter writer = resp.getWriter();
|
||||
writer.println("<H1>" + statusCode + " " + message + "</H1>");
|
||||
}
|
||||
|
||||
private void doResponse(HttpServletResponse resp, int statusCode,
|
||||
String message, Throwable e) throws IOException {
|
||||
resp.setStatus(statusCode);
|
||||
PrintWriter writer = resp.getWriter();
|
||||
writer.println("<H1>" + statusCode + " " + message + "</H1>");
|
||||
writer.println("<pre>");
|
||||
e.printStackTrace(writer);
|
||||
writer.println("</pre>");
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Helper classes
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
private static class AuthException extends Exception {
|
||||
private AuthException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
private static class ParseException extends Exception {
|
||||
private ParseException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
private ParseException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -58,15 +58,16 @@ public class PageController extends FreemarkerHttpServlet{
|
|||
|
||||
if( pageActs == null && dgActs == null){
|
||||
return Actions.AUTHORIZED;
|
||||
}else if( pageActs == null && dgActs != null ){
|
||||
}else if( pageActs == null ){
|
||||
return dgActs;
|
||||
}else if( dgActs == null ){
|
||||
return pageActs;
|
||||
}else{
|
||||
return pageActs;
|
||||
return pageActs.and(dgActs);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
// TODO Auto-generated catch block
|
||||
log.debug(e);
|
||||
log.warn(e);
|
||||
return Actions.UNAUTHORIZED;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -537,13 +537,14 @@ public class PageDaoJena extends JenaBaseDao implements PageDao {
|
|||
List<String> actions = new ArrayList<String>();
|
||||
|
||||
Model dModel = getOntModelSelector().getDisplayModel();
|
||||
dModel.enterCriticalSection(false);
|
||||
try{
|
||||
QueryExecution qe =
|
||||
QueryExecutionFactory.create( requiredActionsQuery, dModel, initialBindings);
|
||||
actions = executeQueryToList( qe );
|
||||
qe.close();
|
||||
}finally{
|
||||
dModel.enterCriticalSection(false);
|
||||
dModel.leaveCriticalSection();
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
|
|
@ -285,7 +285,7 @@ public class FreemarkerTemplateLoader implements TemplateLoader {
|
|||
}
|
||||
|
||||
public boolean fileQualifies(Path path) {
|
||||
return Files.isRegularFile(path) && Files.isReadable(path);
|
||||
return Files.isReadable(path) && !Files.isDirectory(path);
|
||||
}
|
||||
|
||||
public SortedSet<PathPieces> getMatches() {
|
||||
|
|
|
@ -73,8 +73,8 @@ public class ThemeInfoSetup implements ServletContextListener {
|
|||
|
||||
ApplicationBean.themeInfo = new ThemeInfo(themesBaseDir,
|
||||
defaultThemeName, themeNames);
|
||||
ss.info(this, ", current theme: " + currentThemeName
|
||||
+ "default theme: " + defaultThemeName + ", available themes: "
|
||||
ss.info(this, "current theme: " + currentThemeName
|
||||
+ ", default theme: " + defaultThemeName + ", available themes: "
|
||||
+ themeNames);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,96 +0,0 @@
|
|||
/* $This file is distributed under the terms of the license in /doc/license.txt$ */
|
||||
|
||||
package edu.cornell.mannlib.vitro.webapp.utils.dataGetter;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import javax.servlet.ServletContext;
|
||||
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
|
||||
import com.hp.hpl.jena.query.Dataset;
|
||||
import com.hp.hpl.jena.rdf.model.Model;
|
||||
import com.hp.hpl.jena.update.GraphStore;
|
||||
import com.hp.hpl.jena.update.GraphStoreFactory;
|
||||
import com.hp.hpl.jena.update.UpdateAction;
|
||||
|
||||
import edu.cornell.mannlib.vitro.webapp.auth.permissions.SimplePermission;
|
||||
import edu.cornell.mannlib.vitro.webapp.auth.policy.PolicyHelper;
|
||||
import edu.cornell.mannlib.vitro.webapp.auth.requestedAction.Actions;
|
||||
import edu.cornell.mannlib.vitro.webapp.auth.requestedAction.ifaces.RequiresActions;
|
||||
import edu.cornell.mannlib.vitro.webapp.controller.VitroRequest;
|
||||
import edu.cornell.mannlib.vitro.webapp.dao.jena.RDFServiceDataset;
|
||||
import edu.cornell.mannlib.vitro.webapp.search.indexing.IndexBuilder;
|
||||
|
||||
/**
|
||||
* Handle a SPARQL Update request. This uses Jena ARQ and the RDFServiceDataset to
|
||||
* evaluate a SPARQL Update with the RDFService.
|
||||
*
|
||||
* The reason to make this a DataGettere was to allow configuration in RDF of this
|
||||
* service.
|
||||
*/
|
||||
public class SparqlUpdate implements DataGetter, RequiresActions{
|
||||
|
||||
private static final Log log = LogFactory.getLog(SparqlUpdate.class);
|
||||
|
||||
VitroRequest vreq;
|
||||
ServletContext context;
|
||||
|
||||
public SparqlUpdate(
|
||||
VitroRequest vreq, Model displayModel, String dataGetterURI ) {
|
||||
if( vreq == null )
|
||||
throw new IllegalArgumentException("VitroRequest may not be null.");
|
||||
this.vreq = vreq;
|
||||
this.context = vreq.getSession().getServletContext();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Gets the update from the request and then executes it on
|
||||
* the RDFService.
|
||||
*/
|
||||
@Override
|
||||
public Map<String,Object> getData( Map<String, Object> valueMap ) {
|
||||
HashMap<String, Object> data = new HashMap<String,Object>();
|
||||
|
||||
String update = vreq.getParameter("update");
|
||||
|
||||
if( update != null && !update.trim().isEmpty()){
|
||||
try{
|
||||
IndexBuilder.getBuilder(context).pause();
|
||||
Dataset ds = new RDFServiceDataset( vreq.getUnfilteredRDFService() );
|
||||
GraphStore graphStore = GraphStoreFactory.create(ds);
|
||||
log.warn("The SPARQL update is '"+vreq.getParameter("update")+"'");
|
||||
UpdateAction.parseExecute( vreq.getParameter("update") , graphStore );
|
||||
}finally{
|
||||
IndexBuilder.getBuilder(context).unpause();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
data.put("bodyTemplate", "page-sparqlUpdateTest.ftl");
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if this request is authorized by the email/password.
|
||||
* If not do normal authorization.
|
||||
*/
|
||||
@Override
|
||||
public Actions requiredActions(VitroRequest vreq) {
|
||||
String email = vreq.getParameter("email");
|
||||
String password = vreq.getParameter("password");
|
||||
|
||||
boolean isAuth = PolicyHelper.isAuthorizedForActions(vreq,
|
||||
email, password, SimplePermission.MANAGE_SEARCH_INDEX.ACTIONS);
|
||||
|
||||
if( isAuth )
|
||||
return Actions.AUTHORIZED;
|
||||
else
|
||||
return SimplePermission.MANAGE_SEARCH_INDEX.ACTIONS;
|
||||
}
|
||||
|
||||
}
|
|
@ -1025,6 +1025,16 @@
|
|||
<url-pattern>/admin/sparqlquery</url-pattern>
|
||||
</servlet-mapping>
|
||||
|
||||
<servlet>
|
||||
<servlet-name>SparqlUpdateApi</servlet-name>
|
||||
<servlet-class>edu.cornell.mannlib.vitro.webapp.controller.api.SparqlUpdateApiController</servlet-class>
|
||||
</servlet>
|
||||
|
||||
<servlet-mapping>
|
||||
<servlet-name>SparqlUpdateApi</servlet-name>
|
||||
<url-pattern>/api/sparqlUpdate</url-pattern>
|
||||
</servlet-mapping>
|
||||
|
||||
<servlet>
|
||||
<servlet-name>primitiveRdfEdit</servlet-name>
|
||||
<servlet-class>edu.cornell.mannlib.vitro.webapp.controller.edit.PrimitiveRdfEdit</servlet-class>
|
||||
|
|
|
@ -1,12 +0,0 @@
|
|||
<#-- $This file is distributed under the terms of the license in /doc/license.txt$ -->
|
||||
|
||||
<h3>SPARQL Update Test</h3>
|
||||
|
||||
<p>This is an expermental SPARQL update service.</p>
|
||||
|
||||
<form action="${urls.base}/sparqlUpdateTest" method="post">
|
||||
<p>
|
||||
<textarea name="update" rows="20" cols="80" ></textarea>
|
||||
<input type="submit" />
|
||||
</p>
|
||||
</form>
|
Loading…
Add table
Reference in a new issue