Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
@@ -0,0 +1,51 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package contact
import "github.com/opencloud-eu/reva/v2/pkg/siteacc/html"
// PanelTemplate is the content provider for the contact form.
type PanelTemplate struct {
html.ContentProvider
}
// GetTitle returns the title of the panel.
func (template *PanelTemplate) GetTitle() string {
return "ScienceMesh Contact Form"
}
// GetCaption returns the caption which is displayed on the panel.
func (template *PanelTemplate) GetCaption() string {
return "Contact the ScienceMesh administration!"
}
// GetContentJavaScript delivers additional JavaScript code.
func (template *PanelTemplate) GetContentJavaScript() string {
return tplJavaScript
}
// GetContentStyleSheet delivers additional stylesheet code.
func (template *PanelTemplate) GetContentStyleSheet() string {
return tplStyleSheet
}
// GetContentBody delivers the actual body content.
func (template *PanelTemplate) GetContentBody() string {
return tplBody
}
@@ -0,0 +1,111 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package contact
const tplJavaScript = `
function verifyForm(formData) {
if (formData.getTrimmed("subject") == "") {
setState(STATE_ERROR, "Please enter a subject.", "form", "subject", true);
return false;
}
if (formData.getTrimmed("message") == "") {
setState(STATE_ERROR, "Please enter a message.", "form", "message", true);
return false;
}
return true;
}
function handleAction(action) {
const formData = new FormData(document.querySelector("form"));
if (!verifyForm(formData)) {
return;
}
setState(STATE_STATUS, "Sending message... this should only take a moment.", "form", null, false);
var xhr = new XMLHttpRequest();
xhr.open("POST", "{{getServerAddress}}/" + action);
xhr.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
xhr.onload = function() {
if (this.status == 200) {
setState(STATE_SUCCESS, "Your message was successfully sent! A copy of the message has been sent to your email address.");
} else {
var resp = JSON.parse(this.responseText);
setState(STATE_ERROR, "An error occurred while trying to send your message:<br><em>" + resp.error + "</em>", "form", null, true);
}
}
var postData = {
"subject": formData.getTrimmed("subject"),
"message": formData.getTrimmed("message")
};
xhr.send(JSON.stringify(postData));
}
`
const tplStyleSheet = `
html * {
font-family: arial !important;
}
.mandatory {
color: red;
font-weight: bold;
}
`
const tplBody = `
<div>
<p>Contact the ScienceMesh administration using the form below.</p>
<p style="margin-bottom: 0em;">Please include as much information as possible in your request, especially:</p>
<ul style="margin-top: 0em;">
<li>The site your request refers to (if not obvious from your account information)</li>
<li>Your role within the ScienceMesh site (e.g., administrator, operational team member, etc.)</li>
<li>Any specific reasons for your request</li>
<li>Anything else that might help to process your request</li>
</ul>
</div>
<div>&nbsp;</div>
<div>
<form id="form" method="POST" class="box container-inline" style="width: 100%;" onSubmit="handleAction('contact'); return false;">
<div style="grid-row: 1;"><label for="subject">Subject: <span class="mandatory">*</span></label></div>
<div style="grid-row: 2; grid-column: 1 / span 2;"><input type="text" id="subject" name="subject" {{if .Params.Subject}}value="{{.Params.Subject}}" readonly{{end}}/></div>
<div style="grid-row: 3;"><label for="message">Message: <span class="mandatory">*</span></label></div>
<div style="grid-row: 4; grid-column: 1 / span 2;">
<textarea rows="10" id="message" name="message" style="box-sizing: border-box; width: 100%;" {{if .Params.Info}}placeholder="{{.Params.Info}}"{{end}}>{{if .Params.Message}}{{.Params.Message}}{{end}}</textarea>
</div>
<div style="grid-row: 5; align-self: center;">
Fields marked with <span class="mandatory">*</span> are mandatory.
</div>
<div style="grid-row: 5; grid-column: 2; text-align: right;">
<button type="reset">Reset</button>
<button type="submit" style="font-weight: bold;">Send</button>
</div>
</form>
</div>
<div>
<p>Go <a href="{{getServerAddress}}/account/?path=manage">back</a> to the main account page.</p>
</div>
`
@@ -0,0 +1,51 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package edit
import "github.com/opencloud-eu/reva/v2/pkg/siteacc/html"
// PanelTemplate is the content provider for the edit form.
type PanelTemplate struct {
html.ContentProvider
}
// GetTitle returns the title of the panel.
func (template *PanelTemplate) GetTitle() string {
return "ScienceMesh Site Administrator Account"
}
// GetCaption returns the caption which is displayed on the panel.
func (template *PanelTemplate) GetCaption() string {
return "Edit your ScienceMesh Site Administrator Account!"
}
// GetContentJavaScript delivers additional JavaScript code.
func (template *PanelTemplate) GetContentJavaScript() string {
return tplJavaScript
}
// GetContentStyleSheet delivers additional stylesheet code.
func (template *PanelTemplate) GetContentStyleSheet() string {
return tplStyleSheet
}
// GetContentBody delivers the actual body content.
func (template *PanelTemplate) GetContentBody() string {
return tplBody
}
@@ -0,0 +1,158 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package edit
const tplJavaScript = `
function verifyForm(formData) {
if (formData.getTrimmed("fname") == "") {
setState(STATE_ERROR, "Please specify your first name.", "form", "fname", true);
return false;
}
if (formData.getTrimmed("lname") == "") {
setState(STATE_ERROR, "Please specify your last name.", "form", "lname", true);
return false;
}
if (formData.getTrimmed("role") == "") {
setState(STATE_ERROR, "Please specify your role within your site.", "form", "role", true);
return false;
}
if (formData.get("password") != "") {
if (formData.get("password2") == "") {
setState(STATE_ERROR, "Please confirm your new password.", "form", "password2", true);
return false;
}
if (formData.get("password") != formData.get("password2")) {
setState(STATE_ERROR, "The entered passwords do not match.", "form", "password2", true);
return false;
}
}
return true;
}
function handleAction(action) {
const formData = new FormData(document.querySelector("form"));
if (!verifyForm(formData)) {
return;
}
setState(STATE_STATUS, "Updating account... this should only take a moment.", "form", null, false);
var xhr = new XMLHttpRequest();
xhr.open("POST", "{{getServerAddress}}/" + action);
xhr.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
xhr.onload = function() {
if (this.status == 200) {
setState(STATE_SUCCESS, "Your account was successfully updated!", "form", null, true);
} else {
var resp = JSON.parse(this.responseText);
setState(STATE_ERROR, "An error occurred while trying to update your account:<br><em>" + resp.error + "</em>", "form", null, true);
}
}
var postData = {
"title": formData.getTrimmed("title"),
"firstName": formData.getTrimmed("fname"),
"lastName": formData.getTrimmed("lname"),
"role": formData.getTrimmed("role"),
"phoneNumber": formData.getTrimmed("phone"),
"password": {
"value": formData.get("password")
}
};
xhr.send(JSON.stringify(postData));
}
`
const tplStyleSheet = `
html * {
font-family: arial !important;
}
.mandatory {
color: red;
font-weight: bold;
}
`
const tplBody = `
<div>
<p>Edit your ScienceMesh Site Administrator Account information below.</p>
<p>Please note that you cannot modify your email address using this form.</p>
</div>
<div>&nbsp;</div>
<div>
<form id="form" method="POST" class="box container-inline" style="width: 100%;" onSubmit="handleAction('update?invoker=user'); return false;">
<div style="grid-row: 1;"><label for="title">Title: <span class="mandatory">*</span></label></div>
<div style="grid-row: 2;">
<select id="title" name="title">
{{$title := .Account.Title}}
{{range .Titles}}
<option value="{{.}}" {{if eq . $title}}selected{{end}}>{{.}}.</option>
{{end}}
</select>
</div>
<div style="grid-row: 3;"><label for="fname">First name: <span class="mandatory">*</span></label></div>
<div style="grid-row: 4;"><input type="text" id="fname" name="fname" value="{{.Account.FirstName}}"/></div>
<div style="grid-row: 3;"><label for="lname">Last name: <span class="mandatory">*</span></label></div>
<div style="grid-row: 4;"><input type="text" id="lname" name="lname" value="{{.Account.LastName}}"/></div>
<div style="grid-row: 5;"><label for="role">Role: <span class="mandatory">*</span></label></div>
<div style="grid-row: 6;"><input type="text" id="role" name="role" placeholder="Site administrator" value="{{.Account.Role}}"/></div>
<div style="grid-row: 5;"><label for="phone">Phone number:</label></div>
<div style="grid-row: 6;"><input type="text" id="phone" name="phone" placeholder="+49 030 123456" value="{{.Account.PhoneNumber}}"/></div>
<div style="grid-row: 7;">&nbsp;</div>
<div style="grid-row: 8; grid-column: 1 / span 2;">If you want to change your password, fill out the fields below. Otherwise, leave them empty to keep your current one.</div>
<div style="grid-row: 9;"><label for="password">New password:</label></div>
<div style="grid-row: 10;"><input type="password" id="password" name="password" autocomplete="new-password"/></div>
<div style="grid-row: 9"><label for="password2">Confirm new password:</label></div>
<div style="grid-row: 10;"><input type="password" id="password2" name="password2" autocomplete="new-password"/></div>
<div style="grid-row: 11; font-style: italic; font-size: 0.8em;">
The password must fulfil the following criteria:
<ul style="margin-top: 0em;">
<li>Must be at least 8 characters long</li>
<li>Must contain at least 1 lowercase letter</li>
<li>Must contain at least 1 uppercase letter</li>
<li>Must contain at least 1 digit</li>
</ul>
</div>
<div style="grid-row: 12; align-self: center;">
Fields marked with <span class="mandatory">*</span> are mandatory.
</div>
<div style="grid-row: 12; grid-column: 2; text-align: right;">
<button type="reset">Reset</button>
<button type="submit" style="font-weight: bold;">Save</button>
</div>
</form>
</div>
<div>
<p>Go <a href="{{getServerAddress}}/account/?path=manage">back</a> to the main account page.</p>
</div>
`
@@ -0,0 +1,51 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package login
import "github.com/opencloud-eu/reva/v2/pkg/siteacc/html"
// PanelTemplate is the content provider for the login form.
type PanelTemplate struct {
html.ContentProvider
}
// GetTitle returns the title of the panel.
func (template *PanelTemplate) GetTitle() string {
return "ScienceMesh Site Administrator Account Login"
}
// GetCaption returns the caption which is displayed on the panel.
func (template *PanelTemplate) GetCaption() string {
return "Login to your ScienceMesh Site Administrator Account!"
}
// GetContentJavaScript delivers additional JavaScript code.
func (template *PanelTemplate) GetContentJavaScript() string {
return tplJavaScript
}
// GetContentStyleSheet delivers additional stylesheet code.
func (template *PanelTemplate) GetContentStyleSheet() string {
return tplStyleSheet
}
// GetContentBody delivers the actual body content.
func (template *PanelTemplate) GetContentBody() string {
return tplBody
}
@@ -0,0 +1,137 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package login
const tplJavaScript = `
function verifyForm(formData, requirePassword = true) {
if (formData.getTrimmed("email") == "") {
setState(STATE_ERROR, "Please enter your email address.", "form", "email", true);
return false;
}
if (requirePassword) {
if (formData.get("password") == "") {
setState(STATE_ERROR, "Please enter your password.", "form", "password", true);
return false;
}
}
return true;
}
function handleAction(action) {
const formData = new FormData(document.querySelector("form"));
if (!verifyForm(formData)) {
return;
}
setState(STATE_STATUS, "Logging in... this should only take a moment.", "form", null, false);
var xhr = new XMLHttpRequest();
xhr.open("POST", "{{getServerAddress}}/" + action);
xhr.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
xhr.onload = function() {
if (this.status == 200) {
setState(STATE_SUCCESS, "Your login was successful! Redirecting...");
window.location.replace("{{getServerAddress}}/account/?path=manage");
} else {
var resp = JSON.parse(this.responseText);
setState(STATE_ERROR, "An error occurred while trying to login your account:<br><em>" + resp.error + "</em>", "form", null, true);
}
}
var postData = {
"email": formData.getTrimmed("email"),
"password": {
"value": formData.get("password")
}
};
xhr.send(JSON.stringify(postData));
}
function handleResetPassword() {
const formData = new FormData(document.querySelector("form"));
if (!verifyForm(formData, false)) {
return;
}
setState(STATE_STATUS, "Resetting password... this should only take a moment.", "form", null, false);
var xhr = new XMLHttpRequest();
xhr.open("POST", "{{getServerAddress}}/reset-password");
xhr.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
xhr.onload = function() {
if (this.status == 200) {
setState(STATE_SUCCESS, "Your password was successfully reset! Please check your inbox for your new password.", "form", null, true);
} else {
var resp = JSON.parse(this.responseText);
setState(STATE_ERROR, "An error occurred while trying to reset your password:<br><em>" + resp.error + "</em>", "form", null, true);
}
}
var postData = {
"email": formData.get("email")
};
xhr.send(JSON.stringify(postData));
}
`
const tplStyleSheet = `
html * {
font-family: arial !important;
}
.mandatory {
color: red;
font-weight: bold;
}
`
const tplBody = `
<div>
<p>Login to your ScienceMesh Site Administrator Account using the form below.</p>
</div>
<div>&nbsp;</div>
<div>
<form id="form" method="POST" class="box container-inline" style="width: 100%;" onSubmit="handleAction('login'); return false;">
<div style="grid-row: 1;"><label for="email">Email address: <span class="mandatory">*</span></label></div>
<div style="grid-row: 2;"><input type="text" id="email" name="email" placeholder="me@example.com"/></div>
<div style="grid-row: 1;"><label for="password">Password: <span class="mandatory">*</span></label></div>
<div style="grid-row: 2;"><input type="password" id="password" name="password"/></div>
<div style="grid-row: 3; grid-column: 2; font-style: italic; font-size: 0.8em;">
Forgot your password? Click <a href="#" onClick="handleResetPassword();">here</a> to reset it.
</div>
<div style="grid-row: 4; align-self: center;">
Fields marked with <span class="mandatory">*</span> are mandatory.
</div>
<div style="grid-row: 4; grid-column: 2; text-align: right;">
<button type="reset">Reset</button>
<button type="submit" style="font-weight: bold;">Login</button>
</div>
</form>
</div>
<div>
<p>Don't' have an account yet? Register <a href="{{getServerAddress}}/account/?path=register">here</a>.</p>
</div>
`
@@ -0,0 +1,51 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package manage
import "github.com/opencloud-eu/reva/v2/pkg/siteacc/html"
// PanelTemplate is the content provider for the mangement form.
type PanelTemplate struct {
html.ContentProvider
}
// GetTitle returns the title of the panel.
func (template *PanelTemplate) GetTitle() string {
return "ScienceMesh Site Administrator Account"
}
// GetCaption returns the caption which is displayed on the panel.
func (template *PanelTemplate) GetCaption() string {
return "Welcome to your ScienceMesh Site Administrator Account!"
}
// GetContentJavaScript delivers additional JavaScript code.
func (template *PanelTemplate) GetContentJavaScript() string {
return tplJavaScript
}
// GetContentStyleSheet delivers additional stylesheet code.
func (template *PanelTemplate) GetContentStyleSheet() string {
return tplStyleSheet
}
// GetContentBody delivers the actual body content.
func (template *PanelTemplate) GetContentBody() string {
return tplBody
}
@@ -0,0 +1,132 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package manage
const tplJavaScript = `
function handleAccountSettings() {
setState(STATE_STATUS, "Redirecting to the account settings...");
window.location.replace("{{getServerAddress}}/account/?path=settings");
}
function handleEditAccount() {
setState(STATE_STATUS, "Redirecting to the account editor...");
window.location.replace("{{getServerAddress}}/account/?path=edit");
}
function handleSiteSettings() {
setState(STATE_STATUS, "Redirecting to the site settings...");
window.location.replace("{{getServerAddress}}/account/?path=site");
}
function handleRequestAccess(scope) {
setState(STATE_STATUS, "Redirecting to the contact form...");
window.location.replace("{{getServerAddress}}/account/?path=contact&subject=" + encodeURIComponent("Request " + scope + " access"));
}
function handleLogout() {
var xhr = new XMLHttpRequest();
xhr.open("GET", "{{getServerAddress}}/logout");
xhr.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
setState(STATE_STATUS, "Logging out...");
xhr.onload = function() {
if (this.status == 200) {
setState(STATE_SUCCESS, "Done! Redirecting...");
window.location.replace("{{getServerAddress}}/account/?path=login");
} else {
setState(STATE_ERROR, "An error occurred while logging out: " + this.responseText);
}
}
xhr.send();
}
`
const tplStyleSheet = `
html * {
font-family: arial !important;
}
button {
min-width: 170px;
}
`
const tplBody = `
<div>
<p><strong>Hello {{.Account.FirstName}} {{.Account.LastName}},</strong></p>
<p>On this page, you can manage your ScienceMesh Site Administrator Account. This includes editing your personal information, requesting access to the GOCDB and more.</p>
</div>
<div>&nbsp;</div>
<div>
<strong>Personal information:</strong>
<ul style="margin-top: 0em;">
<li>Name: <em>{{.Account.Title}}. {{.Account.FirstName}} {{.Account.LastName}}</em></li>
<li>Email: <em><a href="mailto:{{.Account.Email}}">{{.Account.Email}}</a></em></li>
<li>ScienceMesh Site: <em>{{getSiteName .Account.Site false}} ({{getSiteName .Account.Site true}})</em></li>
<li>Role: <em>{{.Account.Role}}</em></li>
{{if .Account.PhoneNumber}}
<li>Phone: <em>{{.Account.PhoneNumber}}</em></li>
{{end}}
</ul>
</div>
<div>
<strong>Account data:</strong>
<ul style="margin-top: 0em;">
<li>Site access: <em>{{if .Account.Data.SiteAccess}}Granted{{else}}Not granted{{end}}</em></li>
<li>GOCDB access: <em>{{if .Account.Data.GOCDBAccess}}Granted{{else}}Not granted{{end}}</em></li>
</ul>
</div>
<div>
<form id="form" method="POST" class="box" style="width: 100%;">
<div>
<button type="button" onClick="handleAccountSettings();">Account settings</button>
<button type="button" onClick="handleEditAccount();">Edit account</button>
<span style="width: 25px;">&nbsp;</span>
{{if .Account.Data.SiteAccess}}
<button type="button" onClick="handleSiteSettings();">Site settings</button>
<span style="width: 25px;">&nbsp;</span>
{{end}}
<button type="button" onClick="handleLogout();" style="float: right;">Logout</button>
</div>
<div style="margin-top: 0.5em;">
<button type="button" onClick="handleRequestAccess('Site');" {{if .Account.Data.SiteAccess}}disabled{{end}}>Request Site access</button>
<button type="button" onClick="handleRequestAccess('GOCDB');" {{if .Account.Data.GOCDBAccess}}disabled{{end}}>Request GOCDB access</button>
</div>
</form>
</div>
<div style="font-size: 90%; margin-top: 1em;">
<div>
<div>Notes:</div>
<ul style="margin-top: 0em;">
<li>The <em>Site access</em> allows you to access and modify the global configuration of your site.</li>
<li>The <em>GOCDB access</em> allows you to log into the central database where all site metadata is stored.</li>
</ul>
</div>
<div>
<div>Quick links:</div>
<ul style="margin-top: 0em;">
<li><a href="https://gocdb.sciencemesh.uni-muenster.de" target="_blank">Central Database (GOCDB)</a></li>
<li><a href="https://developer.sciencemesh.io/docs/technical-documentation/central-database/" target="_blank">Central Database documentation</a></li>
</ul>
</div>
</div>
`
@@ -0,0 +1,225 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package account
import (
"net/http"
"net/url"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/account/contact"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/account/edit"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/account/login"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/account/manage"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/account/registration"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/account/settings"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/account/site"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/config"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/data"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/html"
"github.com/pkg/errors"
"github.com/rs/zerolog"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
// Panel represents the account panel.
type Panel struct {
html.PanelProvider
conf *config.Configuration
htmlPanel *html.Panel
}
const (
templateLogin = "login"
templateManage = "manage"
templateSettings = "settings"
templateEdit = "edit"
templateSite = "site"
templateContact = "contact"
templateRegistration = "register"
)
func (panel *Panel) initialize(conf *config.Configuration, log *zerolog.Logger) error {
if conf == nil {
return errors.Errorf("no configuration provided")
}
panel.conf = conf
// Create the internal HTML panel
htmlPanel, err := html.NewPanel("account-panel", panel, conf, log)
if err != nil {
return errors.Wrap(err, "unable to create the account panel")
}
panel.htmlPanel = htmlPanel
// Add all templates
if err := panel.htmlPanel.AddTemplate(templateLogin, &login.PanelTemplate{}); err != nil {
return errors.Wrap(err, "unable to create the login template")
}
if err := panel.htmlPanel.AddTemplate(templateManage, &manage.PanelTemplate{}); err != nil {
return errors.Wrap(err, "unable to create the account management template")
}
if err := panel.htmlPanel.AddTemplate(templateSettings, &settings.PanelTemplate{}); err != nil {
return errors.Wrap(err, "unable to create the account settings template")
}
if err := panel.htmlPanel.AddTemplate(templateEdit, &edit.PanelTemplate{}); err != nil {
return errors.Wrap(err, "unable to create the account editing template")
}
if err := panel.htmlPanel.AddTemplate(templateSite, &site.PanelTemplate{}); err != nil {
return errors.Wrap(err, "unable to create the site template")
}
if err := panel.htmlPanel.AddTemplate(templateContact, &contact.PanelTemplate{}); err != nil {
return errors.Wrap(err, "unable to create the contact template")
}
if err := panel.htmlPanel.AddTemplate(templateRegistration, &registration.PanelTemplate{}); err != nil {
return errors.Wrap(err, "unable to create the registration template")
}
return nil
}
// GetActiveTemplate returns the name of the active template.
func (panel *Panel) GetActiveTemplate(session *html.Session, path string) string {
validPaths := []string{templateLogin, templateManage, templateSettings, templateEdit, templateSite, templateContact, templateRegistration}
template := templateLogin
// Only allow valid template paths; redirect to the login page otherwise
for _, valid := range validPaths {
if valid == path {
template = path
break
}
}
return template
}
// PreExecute is called before the actual template is being executed.
func (panel *Panel) PreExecute(session *html.Session, path string, w http.ResponseWriter, r *http.Request) (html.ExecutionResult, error) {
protectedPaths := []string{templateManage, templateSettings, templateEdit, templateSite, templateContact}
if user := session.LoggedInUser(); user != nil {
switch path {
case templateSite:
// If the logged in user doesn't have site access, redirect him back to the main account page
if !user.Account.Data.SiteAccess {
return panel.redirect(templateManage, w, r), nil
}
case templateLogin:
case templateRegistration:
// If a user is logged in and tries to login or register again, redirect to the main account page
return panel.redirect(templateManage, w, r), nil
}
} else {
// If no user is logged in, redirect protected paths to the login page
for _, protected := range protectedPaths {
if protected == path {
return panel.redirect(templateLogin, w, r), nil
}
}
}
return html.ContinueExecution, nil
}
// Execute generates the HTTP output of the form and writes it to the response writer.
func (panel *Panel) Execute(w http.ResponseWriter, r *http.Request, session *html.Session) error {
dataProvider := func(*html.Session) interface{} {
flatValues := make(map[string]string, len(r.URL.Query()))
c := cases.Title(language.Und)
for k, v := range r.URL.Query() {
flatValues[c.String(k)] = v[0]
}
availSites, err := data.QueryAvailableSites(panel.conf.Mentix.URL, panel.conf.Mentix.DataEndpoint)
if err != nil {
return errors.Wrap(err, "unable to query available sites")
}
type TemplateData struct {
Site *data.Site
Account *data.Account
Params map[string]string
Titles []string
Sites []data.SiteInformation
}
tplData := TemplateData{
Site: nil,
Account: nil,
Params: flatValues,
Titles: []string{"Mr", "Mrs", "Ms", "Prof", "Dr"},
Sites: availSites,
}
if user := session.LoggedInUser(); user != nil {
tplData.Site = panel.cloneUserSite(user.Site)
tplData.Account = user.Account
}
return tplData
}
return panel.htmlPanel.Execute(w, r, session, dataProvider)
}
func (panel *Panel) redirect(path string, w http.ResponseWriter, r *http.Request) html.ExecutionResult {
// Check if the original (full) URI path is stored in the request header; if not, use the request URI to get the path
fullPath := r.Header.Get("X-Replaced-Path")
if fullPath == "" {
uri, _ := url.Parse(r.RequestURI)
fullPath = uri.Path
}
// Modify the original request URL by replacing the path parameter
newURL, _ := url.Parse(fullPath)
params := newURL.Query()
params.Del("path")
params.Add("path", path)
newURL.RawQuery = params.Encode()
http.Redirect(w, r, newURL.String(), http.StatusFound)
return html.AbortExecution
}
func (panel *Panel) cloneUserSite(site *data.Site) *data.Site {
// Clone the user's site and decrypt the credentials for the panel
siteClone := site.Clone(true)
id, secret, err := site.Config.TestClientCredentials.Get(panel.conf.Security.CredentialsPassphrase)
if err == nil {
siteClone.Config.TestClientCredentials.ID = id
siteClone.Config.TestClientCredentials.Secret = secret
}
return siteClone
}
// NewPanel creates a new account panel.
func NewPanel(conf *config.Configuration, log *zerolog.Logger) (*Panel, error) {
form := &Panel{}
if err := form.initialize(conf, log); err != nil {
return nil, errors.Wrap(err, "unable to initialize the account panel")
}
return form, nil
}
@@ -0,0 +1,51 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package registration
import "github.com/opencloud-eu/reva/v2/pkg/siteacc/html"
// PanelTemplate is the content provider for the registration form.
type PanelTemplate struct {
html.ContentProvider
}
// GetTitle returns the title of the panel.
func (template *PanelTemplate) GetTitle() string {
return "ScienceMesh Site Administrator Account Registration"
}
// GetCaption returns the caption which is displayed on the panel.
func (template *PanelTemplate) GetCaption() string {
return "Welcome to the ScienceMesh Site Administrator Account Registration!"
}
// GetContentJavaScript delivers additional JavaScript code.
func (template *PanelTemplate) GetContentJavaScript() string {
return tplJavaScript
}
// GetContentStyleSheet delivers additional stylesheet code.
func (template *PanelTemplate) GetContentStyleSheet() string {
return tplStyleSheet
}
// GetContentBody delivers the actual body content.
func (template *PanelTemplate) GetContentBody() string {
return tplBody
}
@@ -0,0 +1,186 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package registration
const tplJavaScript = `
function verifyForm(formData) {
if (formData.getTrimmed("email") == "") {
setState(STATE_ERROR, "Please specify your email address.", "form", "email", true);
return false;
}
if (formData.getTrimmed("fname") == "") {
setState(STATE_ERROR, "Please specify your first name.", "form", "fname", true);
return false;
}
if (formData.getTrimmed("lname") == "") {
setState(STATE_ERROR, "Please specify your last name.", "form", "lname", true);
return false;
}
if (formData.getTrimmed("site") == "") {
setState(STATE_ERROR, "Please select your ScienceMesh site.", "form", "site", true);
return false;
}
if (formData.getTrimmed("role") == "") {
setState(STATE_ERROR, "Please specify your role within your site.", "form", "role", true);
return false;
}
if (formData.get("password") == "") {
setState(STATE_ERROR, "Please set a password.", "form", "password", true);
return false;
}
if (formData.get("password2") == "") {
setState(STATE_ERROR, "Please confirm your password.", "form", "password2", true);
return false;
}
if (formData.get("password") != formData.get("password2")) {
setState(STATE_ERROR, "The entered passwords do not match.", "form", "password2", true);
return false;
}
return true;
}
function handleAction(action) {
const formData = new FormData(document.querySelector("form"));
if (!verifyForm(formData)) {
return;
}
setState(STATE_STATUS, "Sending registration... this should only take a moment.", "form", null, false);
var xhr = new XMLHttpRequest();
xhr.open("POST", "{{getServerAddress}}/" + action);
xhr.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
xhr.onload = function() {
if (this.status == 200) {
setState(STATE_SUCCESS, "Your registration was successful! Please check your inbox for a confirmation email. You will be redirected to the login page in a few seconds (if not, click <a href='{{getServerAddress}}/account/?path=login'>here</a>).");
window.setTimeout(function() {
window.location.replace("{{getServerAddress}}/account/?path=login");
}, 3000);
} else {
var resp = JSON.parse(this.responseText);
setState(STATE_ERROR, "An error occurred while trying to register your account:<br><em>" + resp.error + "</em>", "form", null, true);
}
}
var postData = {
"email": formData.getTrimmed("email"),
"title": formData.getTrimmed("title"),
"firstName": formData.getTrimmed("fname"),
"lastName": formData.getTrimmed("lname"),
"site": formData.getTrimmed("site"),
"role": formData.getTrimmed("role"),
"phoneNumber": formData.getTrimmed("phone"),
"password": {
"value": formData.get("password")
}
};
xhr.send(JSON.stringify(postData));
}
`
const tplStyleSheet = `
html * {
font-family: arial !important;
}
.mandatory {
color: red;
font-weight: bold;
}
`
const tplBody = `
<div>
<p>Fill out the form below to register for a ScienceMesh Site Administrator account. A confirmation email will be sent to you shortly after registration.</p>
</div>
<div>&nbsp;</div>
<div>
<form id="form" method="POST" class="box container-inline" style="width: 100%;" onSubmit="handleAction('create'); return false;">
<div style="grid-row: 1;"><label for="email">Email address: <span class="mandatory">*</span></label></div>
<div style="grid-row: 2;"><input type="text" id="email" name="email" placeholder="me@example.com"/></div>
<div style="grid-row: 1;"><label for="site">ScienceMesh Site: <span class="mandatory">*</span></label></div>
<div style="grid-row: 2;">
<select id="site" name="site">
{{range .Sites}}
<option value="{{.ID}}">{{.Name}} | {{.FullName}}</option>
{{end}}
</select>
</div>
<div style="grid-row: 3;">&nbsp;</div>
<div style="grid-row: 4;"><label for="title">Title: <span class="mandatory">*</span></label></div>
<div style="grid-row: 5;">
<select id="title" name="title">
{{range .Titles}}
<option value="{{.}}">{{.}}.</option>
{{end}}
</select>
</div>
<div style="grid-row: 6;"><label for="fname">First name: <span class="mandatory">*</span></label></div>
<div style="grid-row: 7;"><input type="text" id="fname" name="fname"/></div>
<div style="grid-row: 6;"><label for="lname">Last name: <span class="mandatory">*</span></label></div>
<div style="grid-row: 7;"><input type="text" id="lname" name="lname"/></div>
<div style="grid-row: 8;"><label for="role">Role: <span class="mandatory">*</span></label></div>
<div style="grid-row: 9;"><input type="text" id="role" name="role" placeholder="Site administrator"/></div>
<div style="grid-row: 8;"><label for="phone">Phone number:</label></div>
<div style="grid-row: 9;"><input type="text" id="phone" name="phone" placeholder="+49 030 123456"/></div>
<div style="grid-row: 10;">&nbsp;</div>
<div style="grid-row: 11;"><label for="password">Password: <span class="mandatory">*</span></label></div>
<div style="grid-row: 12;"><input type="password" id="password" name="password"/></div>
<div style="grid-row: 11;"><label for="password2">Confirm password: <span class="mandatory">*</span></label></div>
<div style="grid-row: 12;"><input type="password" id="password2" name="password2"/></div>
<div style="grid-row: 13; font-style: italic; font-size: 0.8em;">
The password must fulfil the following criteria:
<ul style="margin-top: 0em;">
<li>Must be at least 8 characters long</li>
<li>Must contain at least 1 lowercase letter</li>
<li>Must contain at least 1 uppercase letter</li>
<li>Must contain at least 1 digit</li>
</ul>
</div>
<div style="grid-row: 14; align-self: center;">
Fields marked with <span class="mandatory">*</span> are mandatory.
</div>
<div style="grid-row: 14; grid-column: 2; text-align: right;">
<button type="reset">Reset</button>
<button type="submit" style="font-weight: bold;">Register</button>
</div>
</form>
</div>
<div>
<p>Already have an account? Login <a href="{{getServerAddress}}/account/?path=login">here</a>.</p>
</div>
`
@@ -0,0 +1,51 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package settings
import "github.com/opencloud-eu/reva/v2/pkg/siteacc/html"
// PanelTemplate is the content provider for the edit form.
type PanelTemplate struct {
html.ContentProvider
}
// GetTitle returns the title of the panel.
func (template *PanelTemplate) GetTitle() string {
return "ScienceMesh Site Administrator Account"
}
// GetCaption returns the caption which is displayed on the panel.
func (template *PanelTemplate) GetCaption() string {
return "Configure your ScienceMesh Site Administrator Account!"
}
// GetContentJavaScript delivers additional JavaScript code.
func (template *PanelTemplate) GetContentJavaScript() string {
return tplJavaScript
}
// GetContentStyleSheet delivers additional stylesheet code.
func (template *PanelTemplate) GetContentStyleSheet() string {
return tplStyleSheet
}
// GetContentBody delivers the actual body content.
func (template *PanelTemplate) GetContentBody() string {
return tplBody
}
@@ -0,0 +1,93 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package settings
const tplJavaScript = `
function verifyForm(formData) {
return true;
}
function handleAction(action) {
const formData = new FormData(document.querySelector("form"));
if (!verifyForm(formData)) {
return;
}
setState(STATE_STATUS, "Configuring account... this should only take a moment.", "form", null, false);
var xhr = new XMLHttpRequest();
xhr.open("POST", "{{getServerAddress}}/" + action);
xhr.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
xhr.onload = function() {
if (this.status == 200) {
setState(STATE_SUCCESS, "Your account was successfully configured!", "form", null, true);
} else {
var resp = JSON.parse(this.responseText);
setState(STATE_ERROR, "An error occurred while trying to configure your account:<br><em>" + resp.error + "</em>", "form", null, true);
}
}
var postData = {
"settings": {
"receiveAlerts": (formData.get("rcvAlerts") === "on")
}
};
xhr.send(JSON.stringify(postData));
}
`
const tplStyleSheet = `
html * {
font-family: arial !important;
}
input[type="checkbox"] {
width: auto;
}
`
const tplBody = `
<div>
<p>Configure your ScienceMesh Site Administrator Account below.</p>
</div>
<div>&nbsp;</div>
<div>
<form id="form" method="POST" class="box container-inline" style="width: 100%;" onSubmit="handleAction('configure?invoker=user'); return false;">
<div style="grid-row: 1; grid-column: 1 / span 2;">
<h3>Notification settings</h3>
<hr>
</div>
<div style="grid-row: 2; grid-column: 1 / span 2;">
<input type="checkbox" id="rcvAlerts" name="rcvAlerts" value="on" checked disabled/>
<label for="rcvAlerts" style="font-weight: normal;">Receive email notifications about site alerts <em>(mandatory; always on)</em></label>
</div>
<div style="grid-row: 3; grid-column: 2; text-align: right;">
<button type="reset">Reset</button>
<button type="submit" style="font-weight: bold;">Save</button>
</div>
</form>
</div>
<div>
<p>Go <a href="{{getServerAddress}}/account/?path=manage">back</a> to the main account page.</p>
</div>
`
@@ -0,0 +1,51 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package site
import "github.com/opencloud-eu/reva/v2/pkg/siteacc/html"
// PanelTemplate is the content provider for the edit form.
type PanelTemplate struct {
html.ContentProvider
}
// GetTitle returns the title of the panel.
func (template *PanelTemplate) GetTitle() string {
return "ScienceMesh Site Configuration"
}
// GetCaption returns the caption which is displayed on the panel.
func (template *PanelTemplate) GetCaption() string {
return "Configure your ScienceMesh Site!"
}
// GetContentJavaScript delivers additional JavaScript code.
func (template *PanelTemplate) GetContentJavaScript() string {
return tplJavaScript
}
// GetContentStyleSheet delivers additional stylesheet code.
func (template *PanelTemplate) GetContentStyleSheet() string {
return tplStyleSheet
}
// GetContentBody delivers the actual body content.
func (template *PanelTemplate) GetContentBody() string {
return tplBody
}
@@ -0,0 +1,117 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package site
const tplJavaScript = `
function verifyForm(formData) {
if (formData.getTrimmed("clientID") == "") {
setState(STATE_ERROR, "Please enter the name of the test user.", "form", "clientID", true);
return false;
}
if (formData.get("secret") == "") {
setState(STATE_ERROR, "Please enter the password of the test user.", "form", "secret", true);
return false;
}
return true;
}
function handleAction(action) {
const formData = new FormData(document.querySelector("form"));
if (!verifyForm(formData)) {
return;
}
setState(STATE_STATUS, "Configuring site... this should only take a moment.", "form", null, false);
var xhr = new XMLHttpRequest();
xhr.open("POST", "{{getServerAddress}}/" + action);
xhr.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
xhr.onload = function() {
if (this.status == 200) {
setState(STATE_SUCCESS, "Your site was successfully configured!", "form", null, true);
} else {
var resp = JSON.parse(this.responseText);
setState(STATE_ERROR, "An error occurred while trying to configure your site:<br><em>" + resp.error + "</em>", "form", null, true);
}
}
var postData = {
"config": {
"testClientCredentials": {
"id": formData.getTrimmed("clientID"),
"secret": formData.get("secret")
}
}
};
xhr.send(JSON.stringify(postData));
}
`
const tplStyleSheet = `
html * {
font-family: arial !important;
}
input[type="checkbox"] {
width: auto;
}
.mandatory {
color: red;
font-weight: bold;
}
`
const tplBody = `
<div>
<p>Configure your ScienceMesh Site below. <em>These settings affect your entire site and not just your account.</em></p>
</div>
<div>&nbsp;</div>
<div>
<form id="form" method="POST" class="box container-inline" style="width: 100%;" onSubmit="handleAction('site-configure?invoker=user'); return false;">
<div style="grid-row: 1; grid-column: 1 / span 2;">
<h3>Test user settings</h3>
<p>In order to perform automated tests on your site, a test user has to be configured below. Please note that the user <em>has to exist in your Reva instance</em>! If you do not have a user for automated tests in your instance yet, create one first.</p>
<hr>
</div>
<div style="grid-row: 2;"><label for="clientID">User name: <span class="mandatory">*</span></label></div>
<div style="grid-row: 3;"><input type="text" id="clientID" name="clientID" placeholder="User name" value="{{.Site.Config.TestClientCredentials.ID}}"/></div>
<div style="grid-row: 2;"><label for="secret">Password: <span class="mandatory">*</span></label></div>
<div style="grid-row: 3;"><input type="password" id="secret" name="secret" placeholder="Password" value="{{.Site.Config.TestClientCredentials.Secret}}"/></div>
<div style="grid-row: 4;">&nbsp;</div>
<div style="grid-row: 5; align-self: center;">
Fields marked with <span class="mandatory">*</span> are mandatory.
</div>
<div style="grid-row: 5; grid-column: 2; text-align: right;">
<button type="reset">Reset</button>
<button type="submit" style="font-weight: bold;">Save</button>
</div>
</form>
</div>
<div>
<p>Go <a href="{{getServerAddress}}/account/?path=manage">back</a> to the main account page.</p>
</div>
`
@@ -0,0 +1,115 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package admin
import (
"net/http"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/config"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/data"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/html"
"github.com/pkg/errors"
"github.com/rs/zerolog"
)
// Panel represents the web interface panel of the accounts service administration.
type Panel struct {
html.PanelProvider
html.ContentProvider
htmlPanel *html.Panel
}
const (
templateMain = "main"
)
func (panel *Panel) initialize(conf *config.Configuration, log *zerolog.Logger) error {
// Create the internal HTML panel
htmlPanel, err := html.NewPanel("admin-panel", panel, conf, log)
if err != nil {
return errors.Wrap(err, "unable to create the administration panel")
}
panel.htmlPanel = htmlPanel
// Add all templates
if err := panel.htmlPanel.AddTemplate(templateMain, panel); err != nil {
return errors.Wrap(err, "unable to create the main template")
}
return nil
}
// GetActiveTemplate returns the name of the active template.
func (panel *Panel) GetActiveTemplate(*html.Session, string) string {
return templateMain
}
// GetTitle returns the title of the htmlPanel.
func (panel *Panel) GetTitle() string {
return "ScienceMesh Site Administrator Accounts Panel"
}
// GetCaption returns the caption which is displayed on the htmlPanel.
func (panel *Panel) GetCaption() string {
return "ScienceMesh Site Administrator Accounts ({{.Accounts | len}})"
}
// GetContentJavaScript delivers additional JavaScript code.
func (panel *Panel) GetContentJavaScript() string {
return tplJavaScript
}
// GetContentStyleSheet delivers additional stylesheet code.
func (panel *Panel) GetContentStyleSheet() string {
return tplStyleSheet
}
// GetContentBody delivers the actual body content.
func (panel *Panel) GetContentBody() string {
return tplBody
}
// PreExecute is called before the actual template is being executed.
func (panel *Panel) PreExecute(*html.Session, string, http.ResponseWriter, *http.Request) (html.ExecutionResult, error) {
return html.ContinueExecution, nil
}
// Execute generates the HTTP output of the htmlPanel and writes it to the response writer.
func (panel *Panel) Execute(w http.ResponseWriter, r *http.Request, session *html.Session, accounts *data.Accounts) error {
dataProvider := func(*html.Session) interface{} {
type TemplateData struct {
Accounts *data.Accounts
}
return TemplateData{
Accounts: accounts,
}
}
return panel.htmlPanel.Execute(w, r, session, dataProvider)
}
// NewPanel creates a new administration panel.
func NewPanel(conf *config.Configuration, log *zerolog.Logger) (*Panel, error) {
panel := &Panel{}
if err := panel.initialize(conf, log); err != nil {
return nil, errors.Wrap(err, "unable to initialize the administration panel")
}
return panel, nil
}
@@ -0,0 +1,106 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package admin
const tplJavaScript = `
function handleAction(action, email) {
var xhr = new XMLHttpRequest();
xhr.open("POST", "{{getServerAddress}}/" + action);
xhr.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
setState(STATE_STATUS, "Performing request...");
xhr.onload = function() {
if (this.status == 200) {
setState(STATE_SUCCESS, "Done! Reloading...");
location.reload();
} else {
setState(STATE_ERROR, "An error occurred while performing the request: " + this.responseText);
}
}
var postData = {
"email": email,
};
xhr.send(JSON.stringify(postData));
}
`
const tplStyleSheet = `
html * {
font-family: monospace !important;
}
`
const tplBody = `
<div style="font-size: 14px;">
<ul>
{{range .Accounts}}
<li>
<div>
<div>
<strong>{{.Email}}</strong><br>
{{.Title}}. {{.FirstName}} {{.LastName}} <em>(Joined: {{.DateCreated.Format "Jan 02, 2006 15:04"}}; Last modified: {{.DateModified.Format "Jan 02, 2006 15:04"}})</em>
</div>
<div>
<ul style="padding-left: 1em;">
<li>ScienceMesh Site: {{getSiteName .Site false}} ({{getSiteName .Site true}})</li>
<li>Role: {{.Role}}</li>
<li>Phone: {{.PhoneNumber}}</li>
</ul>
</div>
</div>
<div>&nbsp;</div>
<div>
<strong>Account data:</strong>
<ul style="padding-left: 1em; padding-top: 0em;">
<li>Site access: <em>{{if .Data.SiteAccess}}Granted{{else}}Not granted{{end}}</em></li>
<li>GOCDB access: <em>{{if .Data.GOCDBAccess}}Granted{{else}}Not granted{{end}}</em></li>
</ul>
</div>
<div>&nbsp;</div>
<div>
<form method="POST" style="width: 100%;">
{{if .Data.SiteAccess}}
<button type="button" onClick="handleAction('grant-site-access?status=false', '{{.Email}}');">Revoke Site access</button>
{{else}}
<button type="button" onClick="handleAction('grant-site-access?status=true', '{{.Email}}');">Grant Site access</button>
{{end}}
{{if .Data.GOCDBAccess}}
<button type="button" onClick="handleAction('grant-gocdb-access?status=false', '{{.Email}}');">Revoke GOCDB access</button>
{{else}}
<button type="button" onClick="handleAction('grant-gocdb-access?status=true', '{{.Email}}');">Grant GOCDB access</button>
{{end}}
<span style="width: 25px;">&nbsp;</span>
<button type="button" onClick="handleAction('remove', '{{.Email}}');" style="float: right;">Remove</button>
</form>
</div>
<hr>
</li>
{{end}}
</ul>
</div>
`
@@ -0,0 +1,127 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package alerting
import (
"strings"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/config"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/data"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/email"
"github.com/opencloud-eu/reva/v2/pkg/smtpclient"
"github.com/pkg/errors"
"github.com/prometheus/alertmanager/template"
"github.com/rs/zerolog"
)
// Dispatcher is used to dispatch Prometheus alerts via email.
type Dispatcher struct {
conf *config.Configuration
log *zerolog.Logger
smtp *smtpclient.SMTPCredentials
}
func (dispatcher *Dispatcher) initialize(conf *config.Configuration, log *zerolog.Logger) error {
if conf == nil {
return errors.Errorf("no configuration provided")
}
dispatcher.conf = conf
if log == nil {
return errors.Errorf("no logger provided")
}
dispatcher.log = log
// Create the SMTP client
if conf.Email.SMTP != nil {
dispatcher.smtp = smtpclient.NewSMTPCredentials(conf.Email.SMTP)
}
return nil
}
// DispatchAlerts sends the provided alert(s) via email to the appropriate recipients.
func (dispatcher *Dispatcher) DispatchAlerts(alerts *template.Data, accounts data.Accounts) error {
for _, alert := range alerts.Alerts {
siteID, ok := alert.Labels["site_id"]
if !ok {
continue
}
// Dispatch the alert to all accounts configured to receive it
for _, account := range accounts {
if strings.EqualFold(account.Site, siteID) /* && account.Settings.ReceiveAlerts */ { // TODO: Uncomment if alert notifications aren't mandatory anymore
if err := dispatcher.dispatchAlert(alert, account); err != nil {
// Log errors only
dispatcher.log.Err(err).Str("id", alert.Fingerprint).Str("recipient", account.Email).Msg("unable to dispatch alert to user")
}
}
}
// Dispatch the alert to the global receiver (if set)
if dispatcher.conf.Email.NotificationsMail != "" {
globalAccount := data.Account{ // On-the-fly account representing the "global alerts receiver"
Email: dispatcher.conf.Email.NotificationsMail,
FirstName: "ScienceMesh",
LastName: "Global Alerts receiver",
Site: "Global",
Role: "Alerts receiver",
Settings: data.AccountSettings{
ReceiveAlerts: true,
},
}
if err := dispatcher.dispatchAlert(alert, &globalAccount); err != nil {
dispatcher.log.Err(err).Str("id", alert.Fingerprint).Str("recipient", globalAccount.Email).Msg("unable to dispatch alert to global alerts receiver")
}
}
}
return nil
}
func (dispatcher *Dispatcher) dispatchAlert(alert template.Alert, account *data.Account) error {
alertValues := map[string]string{
"Status": alert.Status,
"StartDate": alert.StartsAt.String(),
"EndDate": alert.EndsAt.String(),
"Fingerprint": alert.Fingerprint,
"Name": alert.Labels["alertname"],
"Service": alert.Labels["service_type"],
"Instance": alert.Labels["instance"],
"Job": alert.Labels["job"],
"Severity": alert.Labels["severity"],
"Site": alert.Labels["site"],
"SiteID": alert.Labels["site_id"],
"Description": alert.Annotations["description"],
"Summary": alert.Annotations["summary"],
}
return email.SendAlertNotification(account, []string{account.Email}, alertValues, *dispatcher.conf)
}
// NewDispatcher creates a new dispatcher instance.
func NewDispatcher(conf *config.Configuration, log *zerolog.Logger) (*Dispatcher, error) {
dispatcher := &Dispatcher{}
if err := dispatcher.initialize(conf, log); err != nil {
return nil, errors.Wrap(err, "unable to initialize the alerts dispatcher")
}
return dispatcher, nil
}
@@ -0,0 +1,87 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package config
import (
"strings"
"github.com/opencloud-eu/reva/v2/pkg/smtpclient"
)
// Configuration holds the general service configuration.
type Configuration struct {
Prefix string `mapstructure:"prefix"`
Security struct {
CredentialsPassphrase string `mapstructure:"creds_passphrase"`
} `mapstructure:"security"`
Storage struct {
Driver string `mapstructure:"driver"`
File struct {
SitesFile string `mapstructure:"sites_file"`
AccountsFile string `mapstructure:"accounts_file"`
} `mapstructure:"file"`
} `mapstructure:"storage"`
Email struct {
SMTP *smtpclient.SMTPCredentials `mapstructure:"smtp"`
NotificationsMail string `mapstructure:"notifications_mail"`
} `mapstructure:"email"`
Mentix struct {
URL string `mapstructure:"url"`
DataEndpoint string `mapstructure:"data_endpoint"`
SiteRegistrationEndpoint string `mapstructure:"sitereg_endpoint"`
} `mapstructure:"mentix"`
Webserver struct {
URL string `mapstructure:"url"`
SessionTimeout int `mapstructure:"session_timeout"`
VerifyRemoteAddress bool `mapstructure:"verify_remote_address"`
LogSessions bool `mapstructure:"log_sessions"`
} `mapstructure:"webserver"`
GOCDB struct {
URL string `mapstructure:"url"`
WriteURL string `mapstructure:"write_url"`
APIKey string `mapstructure:"apikey"`
} `mapstructure:"gocdb"`
}
// Cleanup cleans up certain settings, normalizing them.
func (cfg *Configuration) Cleanup() {
// Ensure the webserver URL ends with a slash
if cfg.Webserver.URL != "" && !strings.HasSuffix(cfg.Webserver.URL, "/") {
cfg.Webserver.URL += "/"
}
// Ensure the GOCDB URL ends with a slash
if cfg.GOCDB.URL != "" && !strings.HasSuffix(cfg.GOCDB.URL, "/") {
cfg.GOCDB.URL += "/"
}
// Ensure the GOCDB Write URL ends with a slash
if cfg.GOCDB.WriteURL != "" && !strings.HasSuffix(cfg.GOCDB.WriteURL, "/") {
cfg.GOCDB.WriteURL += "/"
}
}
@@ -0,0 +1,65 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package config
const (
// EndpointAdministration is the endpoint path of the web interface administration panel.
EndpointAdministration = "/admin"
// EndpointAccount is the endpoint path of the web interface account panel.
EndpointAccount = "/account"
// EndpointList is the endpoint path for listing all stored accounts.
EndpointList = "/list"
// EndpointFind is the endpoint path for finding accounts.
EndpointFind = "/find"
// EndpointCreate is the endpoint path for account creation.
EndpointCreate = "/create"
// EndpointUpdate is the endpoint path for account updates.
EndpointUpdate = "/update"
// EndpointConfigure is the endpoint path for account configuration.
EndpointConfigure = "/configure"
// EndpointRemove is the endpoint path for account removal.
EndpointRemove = "/remove"
// EndpointSiteGet is the endpoint path for retrieving site data.
EndpointSiteGet = "/site-get"
// EndpointSiteConfigure is the endpoint path for site configuration.
EndpointSiteConfigure = "/site-configure"
// EndpointLogin is the endpoint path for (internal) user login.
EndpointLogin = "/login"
// EndpointLogout is the endpoint path for (internal) user logout.
EndpointLogout = "/logout"
// EndpointResetPassword is the endpoint path for resetting user passwords
EndpointResetPassword = "/reset-password"
// EndpointContact is the endpoint path for sending contact emails
EndpointContact = "/contact"
// EndpointVerifyUserToken is the endpoint path for user token validation.
EndpointVerifyUserToken = "/verify-user-token"
// EndpointGrantSiteAccess is the endpoint path for granting or revoking Site access.
EndpointGrantSiteAccess = "/grant-site-access"
// EndpointGrantGOCDBAccess is the endpoint path for granting or revoking GOCDB access.
EndpointGrantGOCDBAccess = "/grant-gocdb-access"
// EndpointDispatchAlert is the endpoint path for dispatching alerts from Prometheus.
EndpointDispatchAlert = "/dispatch-alert"
)
@@ -0,0 +1,69 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package credentials
import (
"github.com/opencloud-eu/reva/v2/pkg/siteacc/credentials/crypto"
"github.com/pkg/errors"
)
// Credentials stores and en-/decrypts credentials
type Credentials struct {
ID string `json:"id"`
Secret string `json:"secret"`
}
// Get decrypts and retrieves the stored credentials.
func (creds *Credentials) Get(passphrase string) (string, string, error) {
id, err := crypto.DecodeString(creds.ID, passphrase)
if err != nil {
return "", "", errors.Wrap(err, "unable to decode ID")
}
secret, err := crypto.DecodeString(creds.Secret, passphrase)
if err != nil {
return "", "", errors.Wrap(err, "unable to decode secret")
}
return id, secret, nil
}
// Set encrypts and sets new credentials.
func (creds *Credentials) Set(id, secret string, passphrase string) error {
if s, err := crypto.EncodeString(id, passphrase); err == nil {
creds.ID = s
} else {
return errors.Wrap(err, "unable to encode ID")
}
if s, err := crypto.EncodeString(secret, passphrase); err == nil {
creds.Secret = s
} else {
return errors.Wrap(err, "unable to encode secret")
}
return nil
}
// IsValid checks whether the credentials are valid.
func (creds *Credentials) IsValid() bool {
return len(creds.ID) > 0 && len(creds.Secret) > 0
}
// Clear resets the credentials.
func (creds *Credentials) Clear() {
creds.ID = ""
creds.Secret = ""
}
@@ -0,0 +1,101 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"io"
"github.com/pkg/errors"
)
const (
passphraseLength = 32
)
// EncodeString encodes a string using AES and returns the base64-encoded result.
func EncodeString(s string, passphrase string) (string, error) {
if len(s) == 0 || len(passphrase) == 0 {
return "", nil
}
passphrase = normalizePassphrase(passphrase)
gcm, err := createGCM([]byte(passphrase))
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
return "", errors.Wrap(err, "unable to generate nonce")
}
encryptedData := gcm.Seal(nonce, nonce, []byte(s), nil)
return base64.StdEncoding.EncodeToString(encryptedData), nil
}
// DecodeString decodes a base64-encoded string encoded with AES.
func DecodeString(s string, passphrase string) (string, error) {
if len(s) == 0 || len(passphrase) == 0 {
return "", nil
}
data, _ := base64.StdEncoding.DecodeString(s)
passphrase = normalizePassphrase(passphrase)
gcm, err := createGCM([]byte(passphrase))
if err != nil {
return "", err
}
nonceSize := gcm.NonceSize()
if len(s) < nonceSize {
return "", errors.Errorf("input string length too short")
}
nonce, data := data[:nonceSize], data[nonceSize:]
plain, err := gcm.Open(nil, nonce, data, nil)
if err != nil {
return "", errors.Wrap(err, "unable to decode string")
}
return string(plain), nil
}
func createGCM(passphrase []byte) (cipher.AEAD, error) {
c, err := aes.NewCipher(passphrase)
if err != nil {
return nil, errors.Wrap(err, "unable to generate cipher")
}
gcm, err := cipher.NewGCM(c)
if err != nil {
return nil, errors.Wrap(err, "unable to generate GCM")
}
return gcm, nil
}
func normalizePassphrase(passphrase string) string {
if len(passphrase) > passphraseLength {
passphrase = passphrase[:passphraseLength]
} else if len(passphrase) < passphraseLength {
for i := len(passphrase); i < passphraseLength; i++ {
passphrase += "#"
}
}
return passphrase
}
@@ -0,0 +1,83 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package credentials
import (
"strings"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
)
// Password holds a hash password alongside its salt value.
type Password struct {
Value string `json:"value"`
}
const (
passwordMinLength = 8
)
// Set sets a new password by hashing the plaintext version using bcrypt.
func (password *Password) Set(pwd string) error {
if err := VerifyPassword(pwd); err != nil {
return errors.Wrap(err, "invalid password")
}
pwdData, err := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost)
if err != nil {
return errors.Wrap(err, "unable to generate password hash")
}
password.Value = string(pwdData)
return nil
}
// Compare checks whether the given password string equals the stored one.
func (password *Password) Compare(pwd string) bool {
return bcrypt.CompareHashAndPassword([]byte(password.Value), []byte(pwd)) == nil
}
// IsValid checks whether the password is valid.
func (password *Password) IsValid() bool {
// bcrypt hashes are in the form of $[version]$[cost]$[22 character salt][31 character hash], so they have a minimum length of 58
return len(password.Value) > 58 && strings.Count(password.Value, "$") >= 3
}
// Clear resets the password.
func (password *Password) Clear() {
password.Value = ""
}
// VerifyPassword checks whether the given password abides to the enforced password strength.
func VerifyPassword(pwd string) error {
if len(pwd) < passwordMinLength {
return errors.Errorf("the password must be at least 8 characters long")
}
if !strings.ContainsAny(pwd, "abcdefghijklmnopqrstuvwxyz") {
return errors.Errorf("the password must contain at least one lowercase letter")
}
if !strings.ContainsAny(pwd, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
return errors.Errorf("the password must contain at least one uppercase letter")
}
if !strings.ContainsAny(pwd, "0123456789") {
return errors.Errorf("the password must contain at least one digit")
}
return nil
}
@@ -0,0 +1,222 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this filePath except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package data
import (
"strings"
"time"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/credentials"
"github.com/pkg/errors"
"github.com/opencloud-eu/reva/v2/pkg/utils"
)
// Account represents a single site account.
type Account struct {
Email string `json:"email"`
Title string `json:"title"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
Site string `json:"site"`
Role string `json:"role"`
PhoneNumber string `json:"phoneNumber"`
Password credentials.Password `json:"password"`
DateCreated time.Time `json:"dateCreated"`
DateModified time.Time `json:"dateModified"`
Data AccountData `json:"data"`
Settings AccountSettings `json:"settings"`
}
// AccountData holds additional data for a site account.
type AccountData struct {
GOCDBAccess bool `json:"gocdbAccess"`
SiteAccess bool `json:"siteAccess"`
}
// AccountSettings holds additional settings for a site account.
type AccountSettings struct {
ReceiveAlerts bool `json:"receiveAlerts"`
}
// Accounts holds an array of site accounts.
type Accounts = []*Account
// Update copies the data of the given account to this account.
func (acc *Account) Update(other *Account, setPassword bool, copyData bool) error {
if err := other.verify(false, false); err != nil {
return errors.Wrap(err, "unable to update account data")
}
// Manually update fields
acc.Title = other.Title
acc.FirstName = other.FirstName
acc.LastName = other.LastName
acc.Role = other.Role
acc.PhoneNumber = other.PhoneNumber
if setPassword && other.Password.Value != "" {
// If a password was provided, use that as the new one
if err := acc.UpdatePassword(other.Password.Value); err != nil {
return errors.Wrap(err, "unable to update account data")
}
}
if copyData {
acc.Data = other.Data
}
return nil
}
// Configure copies the settings of the given account to this account.
func (acc *Account) Configure(other *Account) error {
// Simply copy the stored settings
acc.Settings = other.Settings
return nil
}
// UpdatePassword assigns a new password to the account, hashing it first.
func (acc *Account) UpdatePassword(pwd string) error {
if err := acc.Password.Set(pwd); err != nil {
return errors.Wrap(err, "unable to update the user password")
}
return nil
}
// Clone creates a copy of the account; if erasePassword is set to true, the password will be cleared in the cloned object.
func (acc *Account) Clone(erasePassword bool) *Account {
clone := *acc
if erasePassword {
clone.Password.Clear()
}
return &clone
}
// CheckScopeAccess checks whether the user can access the specified scope.
func (acc *Account) CheckScopeAccess(scope string) bool {
hasAccess := false
switch strings.ToLower(scope) {
case ScopeDefault:
hasAccess = true
case ScopeGOCDB:
hasAccess = acc.Data.GOCDBAccess
case ScopeSite:
hasAccess = acc.Data.SiteAccess
}
return hasAccess
}
// Cleanup trims all string entries.
func (acc *Account) Cleanup() {
acc.Email = strings.TrimSpace(acc.Email)
acc.Title = strings.TrimSpace(acc.Title)
acc.FirstName = strings.TrimSpace(acc.FirstName)
acc.LastName = strings.TrimSpace(acc.LastName)
acc.Site = strings.TrimSpace(acc.Site)
acc.Role = strings.TrimSpace(acc.Role)
acc.PhoneNumber = strings.TrimSpace(acc.PhoneNumber)
}
func (acc *Account) verify(isNewAccount, verifyPassword bool) error {
if acc.Email == "" {
return errors.Errorf("no email address provided")
} else if !utils.IsEmailValid(acc.Email) {
return errors.Errorf("invalid email address: %v", acc.Email)
}
if acc.FirstName == "" {
return errors.Errorf("no first name provided")
} else if !utils.IsValidName(acc.FirstName) {
return errors.Errorf("first name contains invalid characters: %v", acc.FirstName)
}
if acc.LastName == "" {
return errors.Errorf("no last name provided")
} else if !utils.IsValidName(acc.LastName) {
return errors.Errorf("last name contains invalid characters: %v", acc.LastName)
}
if isNewAccount && acc.Site == "" {
return errors.Errorf("no site provided")
}
if acc.Role == "" {
return errors.Errorf("no role provided")
} else if !utils.IsValidName(acc.Role) {
return errors.Errorf("role contains invalid characters: %v", acc.Role)
}
if acc.PhoneNumber != "" && !utils.IsValidPhoneNumber(acc.PhoneNumber) {
return errors.Errorf("invalid phone number provided")
}
if verifyPassword {
if !acc.Password.IsValid() {
return errors.Errorf("no valid password set")
}
}
return nil
}
// NewAccount creates a new site account.
func NewAccount(email string, title, firstName, lastName string, site, role string, phoneNumber string, password string) (*Account, error) {
t := time.Now()
acc := &Account{
Email: email,
Title: title,
FirstName: firstName,
LastName: lastName,
Site: site,
Role: role,
PhoneNumber: phoneNumber,
DateCreated: t,
DateModified: t,
Data: AccountData{
GOCDBAccess: false,
SiteAccess: false,
},
Settings: AccountSettings{
ReceiveAlerts: true,
},
}
// Set the user password, which also makes sure that the given password is strong enough
if err := acc.UpdatePassword(password); err != nil {
return nil, err
}
if err := acc.verify(true, true); err != nil {
return nil, err
}
return acc, nil
}
@@ -0,0 +1,164 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this filePath except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package data
import (
"encoding/json"
"os"
"path/filepath"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/config"
"github.com/pkg/errors"
"github.com/rs/zerolog"
)
// FileStorage implements a filePath-based storage.
type FileStorage struct {
Storage
conf *config.Configuration
log *zerolog.Logger
sitesFilePath string
accountsFilePath string
}
func (storage *FileStorage) initialize(conf *config.Configuration, log *zerolog.Logger) error {
if conf == nil {
return errors.Errorf("no configuration provided")
}
storage.conf = conf
if log == nil {
return errors.Errorf("no logger provided")
}
storage.log = log
if conf.Storage.File.SitesFile == "" {
return errors.Errorf("no sites file set in the configuration")
}
storage.sitesFilePath = conf.Storage.File.SitesFile
if conf.Storage.File.AccountsFile == "" {
return errors.Errorf("no accounts file set in the configuration")
}
storage.accountsFilePath = conf.Storage.File.AccountsFile
// Create the file directories if necessary
_ = os.MkdirAll(filepath.Dir(storage.sitesFilePath), 0755)
_ = os.MkdirAll(filepath.Dir(storage.accountsFilePath), 0755)
return nil
}
func (storage *FileStorage) readData(file string, obj interface{}) error {
// Read the data from the specified file
jsonData, err := os.ReadFile(file)
if err != nil {
return errors.Wrapf(err, "unable to read file %v", file)
}
if err := json.Unmarshal(jsonData, obj); err != nil {
return errors.Wrapf(err, "invalid file %v", file)
}
return nil
}
// ReadSites reads all stored sites into the given data object.
func (storage *FileStorage) ReadSites() (*Sites, error) {
sites := &Sites{}
if err := storage.readData(storage.sitesFilePath, sites); err != nil {
return nil, errors.Wrap(err, "error reading sites")
}
return sites, nil
}
// ReadAccounts reads all stored accounts into the given data object.
func (storage *FileStorage) ReadAccounts() (*Accounts, error) {
accounts := &Accounts{}
if err := storage.readData(storage.accountsFilePath, accounts); err != nil {
return nil, errors.Wrap(err, "error reading accounts")
}
return accounts, nil
}
func (storage *FileStorage) writeData(file string, obj interface{}) error {
// Write the data to the specified file
jsonData, _ := json.MarshalIndent(obj, "", "\t")
if err := os.WriteFile(file, jsonData, 0755); err != nil {
return errors.Wrapf(err, "unable to write file %v", file)
}
return nil
}
// WriteSites writes all stored sites from the given data object.
func (storage *FileStorage) WriteSites(sites *Sites) error {
if err := storage.writeData(storage.sitesFilePath, sites); err != nil {
return errors.Wrap(err, "error writing sites")
}
return nil
}
// WriteAccounts writes all stored accounts from the given data object.
func (storage *FileStorage) WriteAccounts(accounts *Accounts) error {
if err := storage.writeData(storage.accountsFilePath, accounts); err != nil {
return errors.Wrap(err, "error writing accounts")
}
return nil
}
// SiteAdded is called when a site has been added.
func (storage *FileStorage) SiteAdded(site *Site) {
// Simply skip this action; all data is saved solely in WriteSites
}
// SiteUpdated is called when a site has been updated.
func (storage *FileStorage) SiteUpdated(site *Site) {
// Simply skip this action; all data is saved solely in WriteSites
}
// SiteRemoved is called when a site has been removed.
func (storage *FileStorage) SiteRemoved(site *Site) {
// Simply skip this action; all data is saved solely in WriteSites
}
// AccountAdded is called when an account has been added.
func (storage *FileStorage) AccountAdded(account *Account) {
// Simply skip this action; all data is saved solely in WriteAccounts
}
// AccountUpdated is called when an account has been updated.
func (storage *FileStorage) AccountUpdated(account *Account) {
// Simply skip this action; all data is saved solely in WriteAccounts
}
// AccountRemoved is called when an account has been removed.
func (storage *FileStorage) AccountRemoved(account *Account) {
// Simply skip this action; all data is saved solely in WriteAccounts
}
// NewFileStorage creates a new file storage.
func NewFileStorage(conf *config.Configuration, log *zerolog.Logger) (*FileStorage, error) {
storage := &FileStorage{}
if err := storage.initialize(conf, log); err != nil {
return nil, errors.Wrap(err, "unable to initialize the file storage")
}
return storage, nil
}
@@ -0,0 +1,28 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package data
const (
// ScopeDefault is the default account panel scope.
ScopeDefault = ""
// ScopeGOCDB is used to access the GOCDB.
ScopeGOCDB = "gocdb"
// ScopeSite is used to access the global site configuration.
ScopeSite = "site"
)
+81
View File
@@ -0,0 +1,81 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package data
import (
"github.com/opencloud-eu/reva/v2/pkg/siteacc/credentials"
"github.com/pkg/errors"
)
// Site represents the global site-specific settings stored in the service.
type Site struct {
ID string `json:"id"`
Config SiteConfiguration `json:"config"`
}
// SiteConfiguration stores the global configuration of a site.
type SiteConfiguration struct {
TestClientCredentials credentials.Credentials `json:"testClientCredentials"`
}
// Sites holds an array of sites.
type Sites = []*Site
// Update copies the data of the given site to this site.
func (site *Site) Update(other *Site, credsPassphrase string) error {
if other.Config.TestClientCredentials.IsValid() {
// If credentials were provided, use those as the new ones
if err := site.UpdateTestClientCredentials(other.Config.TestClientCredentials.ID, other.Config.TestClientCredentials.Secret, credsPassphrase); err != nil {
return err
}
}
return nil
}
// UpdateTestClientCredentials assigns new test client credentials, encrypting the information first.
func (site *Site) UpdateTestClientCredentials(id, secret string, passphrase string) error {
if err := site.Config.TestClientCredentials.Set(id, secret, passphrase); err != nil {
return errors.Wrap(err, "unable to update the test client credentials")
}
return nil
}
// Clone creates a copy of the site; if eraseCredentials is set to true, the (test user) credentials will be cleared in the cloned object.
func (site *Site) Clone(eraseCredentials bool) *Site {
clone := *site
if eraseCredentials {
clone.Config.TestClientCredentials.Clear()
}
return &clone
}
// NewSite creates a new site.
func NewSite(id string) (*Site, error) {
site := &Site{
ID: id,
Config: SiteConfiguration{
TestClientCredentials: credentials.Credentials{},
},
}
return site, nil
}
@@ -0,0 +1,89 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package data
import (
"encoding/json"
"sort"
"github.com/opencloud-eu/reva/v2/pkg/mentix/utils/network"
"github.com/pkg/errors"
)
// SiteInformation holds the most basic information about a site.
type SiteInformation struct {
ID string
Name string
FullName string
}
// QueryAvailableSites uses Mentix to query a list of all available (registered) sites.
func QueryAvailableSites(mentixHost, dataEndpoint string) ([]SiteInformation, error) {
mentixURL, err := network.GenerateURL(mentixHost, dataEndpoint, network.URLParams{})
if err != nil {
return nil, errors.Wrap(err, "unable to generate Mentix URL")
}
data, err := network.ReadEndpoint(mentixURL, nil, true)
if err != nil {
return nil, errors.Wrap(err, "unable to read the Mentix endpoint")
}
// Decode the data into a simplified, reduced data type
type siteData struct {
Sites []SiteInformation
}
sites := siteData{}
if err := json.Unmarshal(data, &sites); err != nil {
return nil, errors.Wrap(err, "error while decoding the JSON data")
}
// Sort the sites alphabetically by their names
sort.Slice(sites.Sites, func(i, j int) bool {
return sites.Sites[i].Name < sites.Sites[j].Name
})
return sites.Sites, nil
}
// QuerySiteName uses Mentix to query the name of a site given by its ID.
func QuerySiteName(siteID string, fullName bool, mentixHost, dataEndpoint string) (string, error) {
sites, err := QueryAvailableSites(mentixHost, dataEndpoint)
if err != nil {
return "", err
}
index := len(sites)
for i, site := range sites {
if site.ID == siteID {
index = i
break
}
}
if index != len(sites) {
if fullName {
return sites[index].FullName, nil
}
return sites[index].Name, nil
}
return "", errors.Errorf("no site with ID %v found", siteID)
}
@@ -0,0 +1,46 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this filePath except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package data
// Storage defines the interface for sites and accounts storages.
type Storage interface {
// ReadSites reads all stored sites into the given data object.
ReadSites() (*Sites, error)
// WriteSites writes all stored sites from the given data object.
WriteSites(sites *Sites) error
// SiteAdded is called when a site has been added.
SiteAdded(site *Site)
// SiteUpdated is called when a site has been updated.
SiteUpdated(site *Site)
// SiteRemoved is called when a site has been removed.
SiteRemoved(site *Site)
// ReadAccounts reads all stored accounts into the given data object.
ReadAccounts() (*Accounts, error)
// WriteAccounts writes all stored accounts from the given data object.
WriteAccounts(accounts *Accounts) error
// AccountAdded is called when an account has been added.
AccountAdded(account *Account)
// AccountUpdated is called when an account has been updated.
AccountUpdated(account *Account)
// AccountRemoved is called when an account has been removed.
AccountRemoved(account *Account)
}
@@ -0,0 +1,134 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package email
import (
"bytes"
"strings"
"text/template"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/config"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/data"
"github.com/opencloud-eu/reva/v2/pkg/smtpclient"
"github.com/pkg/errors"
)
type emailData struct {
Account *data.Account
AccountsAddress string
GOCDBAddress string
Params map[string]string
}
// SendFunction is the definition of email send functions.
type SendFunction = func(*data.Account, []string, map[string]string, config.Configuration) error
func getEmailData(account *data.Account, conf config.Configuration, params map[string]string) *emailData {
return &emailData{
Account: account,
AccountsAddress: conf.Webserver.URL,
GOCDBAddress: conf.GOCDB.URL,
Params: params,
}
}
// SendAccountCreated sends an email about account creation.
func SendAccountCreated(account *data.Account, recipients []string, params map[string]string, conf config.Configuration) error {
return send(recipients, "ScienceMesh: Site Administrator Account created", accountCreatedTemplate, getEmailData(account, conf, params), conf.Email.SMTP)
}
// SendSiteAccessGranted sends an email about granted Site access.
func SendSiteAccessGranted(account *data.Account, recipients []string, params map[string]string, conf config.Configuration) error {
return send(recipients, "ScienceMesh: Site access granted", siteAccessGrantedTemplate, getEmailData(account, conf, params), conf.Email.SMTP)
}
// SendGOCDBAccessGranted sends an email about granted GOCDB access.
func SendGOCDBAccessGranted(account *data.Account, recipients []string, params map[string]string, conf config.Configuration) error {
return send(recipients, "ScienceMesh: GOCDB access granted", gocdbAccessGrantedTemplate, getEmailData(account, conf, params), conf.Email.SMTP)
}
// SendPasswordReset sends an email containing the user's new password.
func SendPasswordReset(account *data.Account, recipients []string, params map[string]string, conf config.Configuration) error {
return send(recipients, "ScienceMesh: Password reset", passwordResetTemplate, getEmailData(account, conf, params), conf.Email.SMTP)
}
// SendContactForm sends a generic contact form to the ScienceMesh admins.
func SendContactForm(account *data.Account, recipients []string, params map[string]string, conf config.Configuration) error {
return send(recipients, "ScienceMesh: Contact form", contactFormTemplate, getEmailData(account, conf, params), conf.Email.SMTP)
}
// SendAlertNotification sends an alert via email.
func SendAlertNotification(account *data.Account, recipients []string, params map[string]string, conf config.Configuration) error {
subject := params["Summary"]
tpl := alertFiringNotificationTemplate
if strings.EqualFold(params["Status"], "resolved") {
tpl = alertResolvedNotificationTemplate
subject += " [RESOLVED]"
}
return send(recipients, "ScienceMesh Alert: "+subject, tpl, getEmailData(account, conf, params), conf.Email.SMTP)
}
func send(recipients []string, subject string, bodyTemplate string, data interface{}, smtp *smtpclient.SMTPCredentials) error {
// Do not fail if no SMTP client or recipient is given
if smtp == nil {
return nil
}
tpl := template.New("email")
prepareEmailTemplate(tpl)
if _, err := tpl.Parse(bodyTemplate); err != nil {
return errors.Wrap(err, "error while parsing email template")
}
var body bytes.Buffer
if err := tpl.Execute(&body, data); err != nil {
return errors.Wrap(err, "error while executing email template")
}
for _, recipient := range recipients {
if len(recipient) == 0 {
continue
}
// Send the mail w/o blocking the main thread
go func(recipient string) {
_ = smtp.SendMail(recipient, subject, body.String())
}(recipient)
}
return nil
}
func prepareEmailTemplate(tpl *template.Template) {
// Add some custom helper functions to the template
tpl.Funcs(template.FuncMap{
"indent": func(n int, s string) string {
lines := make([]string, 0, 10)
for _, line := range strings.Split(s, "\n") {
line = strings.TrimSpace(line)
line = strings.Repeat(" ", n) + line
lines = append(lines, line)
}
return strings.Join(lines, "\n")
},
})
}
@@ -0,0 +1,106 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package email
const accountCreatedTemplate = `
Dear {{.Account.FirstName}} {{.Account.LastName}},
Your ScienceMesh Site Administrator Account has been successfully created!
Log in to your account by visiting the user account panel:
{{.AccountsAddress}}
Using this panel, you can modify your information, request access to the GOCDB, and more.
Kind regards,
The ScienceMesh Team
`
const siteAccessGrantedTemplate = `
Dear {{.Account.FirstName}} {{.Account.LastName}},
You have been granted access to the global configuration of your site.
Log in to your account to access this configuration:
{{.AccountsAddress}}
Kind regards,
The ScienceMesh Team
`
const gocdbAccessGrantedTemplate = `
Dear {{.Account.FirstName}} {{.Account.LastName}},
You have been granted access to the ScienceMesh GOCDB instance:
{{.GOCDBAddress}}
Simply use your regular ScienceMesh Site Administrator Account credentials to log in to the GOCDB.
Kind regards,
The ScienceMesh Team
`
const passwordResetTemplate = `
Dear {{.Account.FirstName}} {{.Account.LastName}},
Your password has been successfully reset!
Your new password is: {{.Account.Password.Value}}
We recommend to change this password immediately after logging in.
Kind regards,
The ScienceMesh Team
`
const contactFormTemplate = `
{{.Account.FirstName}} {{.Account.LastName}} ({{.Account.Email}}) has sent the following message:
{{.Params.Subject}}
---------------------------------------------------------------------------------------------------
{{.Params.Message}}
`
const alertFiringNotificationTemplate = `
Site '{{.Params.Site}}' has generated an alert:
Type: {{.Params.Name}}
Service: {{.Params.Service}}
Instance: {{.Params.Instance}}
Job: {{.Params.Job}}
Severity: {{.Params.Severity}}
{{.Params.Description | indent 2}}
{{.Params.StartDate}} ({{.Params.Fingerprint}})
`
const alertResolvedNotificationTemplate = `
Site '{{.Params.Site}}' has resolved an alert:
Type: {{.Params.Name}}
Service: {{.Params.Service}}
Instance: {{.Params.Instance}}
Job: {{.Params.Job}}
Severity: {{.Params.Severity}}
{{.Params.Description | indent 2}}
{{.Params.StartDate}} - {{.Params.EndDate}} ({{.Params.Fingerprint}})
`
+440
View File
@@ -0,0 +1,440 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package siteacc
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/config"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/data"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/html"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/manager"
"github.com/pkg/errors"
"github.com/prometheus/alertmanager/template"
)
const (
invokerUser = "user"
)
type methodCallback = func(*SiteAccounts, url.Values, []byte, *html.Session) (interface{}, error)
type accessSetterCallback = func(*manager.AccountsManager, *data.Account, bool) error
type endpoint struct {
Path string
Handler func(*SiteAccounts, endpoint, http.ResponseWriter, *http.Request, *html.Session)
MethodCallbacks map[string]methodCallback
IsPublic bool
}
func createMethodCallbacks(cbGet methodCallback, cbPost methodCallback) map[string]methodCallback {
callbacks := make(map[string]methodCallback)
if cbGet != nil {
callbacks[http.MethodGet] = cbGet
}
if cbPost != nil {
callbacks[http.MethodPost] = cbPost
}
return callbacks
}
func getEndpoints() []endpoint {
endpoints := []endpoint{
// Form/panel endpoints
{config.EndpointAdministration, callAdministrationEndpoint, nil, false},
{config.EndpointAccount, callAccountEndpoint, nil, true},
// General account endpoints
{config.EndpointList, callMethodEndpoint, createMethodCallbacks(handleList, nil), false},
{config.EndpointFind, callMethodEndpoint, createMethodCallbacks(handleFind, nil), false},
{config.EndpointCreate, callMethodEndpoint, createMethodCallbacks(nil, handleCreate), true},
{config.EndpointUpdate, callMethodEndpoint, createMethodCallbacks(nil, handleUpdate), false},
{config.EndpointConfigure, callMethodEndpoint, createMethodCallbacks(nil, handleConfigure), false},
{config.EndpointRemove, callMethodEndpoint, createMethodCallbacks(nil, handleRemove), false},
// Site endpoints
{config.EndpointSiteGet, callMethodEndpoint, createMethodCallbacks(handleSiteGet, nil), false},
{config.EndpointSiteConfigure, callMethodEndpoint, createMethodCallbacks(nil, handleSiteConfigure), false},
// Login endpoints
{config.EndpointLogin, callMethodEndpoint, createMethodCallbacks(nil, handleLogin), true},
{config.EndpointLogout, callMethodEndpoint, createMethodCallbacks(handleLogout, nil), true},
{config.EndpointResetPassword, callMethodEndpoint, createMethodCallbacks(nil, handleResetPassword), true},
{config.EndpointContact, callMethodEndpoint, createMethodCallbacks(nil, handleContact), true},
// Authentication endpoints
{config.EndpointVerifyUserToken, callMethodEndpoint, createMethodCallbacks(handleVerifyUserToken, nil), true},
// Access management endpoints
{config.EndpointGrantSiteAccess, callMethodEndpoint, createMethodCallbacks(nil, handleGrantSiteAccess), false},
{config.EndpointGrantGOCDBAccess, callMethodEndpoint, createMethodCallbacks(nil, handleGrantGOCDBAccess), false},
// Alerting endpoints
{config.EndpointDispatchAlert, callMethodEndpoint, createMethodCallbacks(nil, handleDispatchAlert), false},
}
return endpoints
}
func callAdministrationEndpoint(siteacc *SiteAccounts, ep endpoint, w http.ResponseWriter, r *http.Request, session *html.Session) {
if err := siteacc.ShowAdministrationPanel(w, r, session); err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = fmt.Fprintf(w, "Unable to show the administration panel: %v", err)
}
}
func callAccountEndpoint(siteacc *SiteAccounts, ep endpoint, w http.ResponseWriter, r *http.Request, session *html.Session) {
if err := siteacc.ShowAccountPanel(w, r, session); err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = fmt.Fprintf(w, "Unable to show the account panel: %v", err)
}
}
func callMethodEndpoint(siteacc *SiteAccounts, ep endpoint, w http.ResponseWriter, r *http.Request, session *html.Session) {
// Every request to the accounts service results in a standardized JSON response
type Response struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
Data interface{} `json:"data,omitempty"`
}
// The default response is an unknown requestHandler (for the specified method)
resp := Response{
Success: false,
Error: fmt.Sprintf("unknown endpoint %v for method %v", r.URL.Path, r.Method),
Data: nil,
}
if ep.MethodCallbacks != nil {
// Search for a matching method in the list of callbacks
for method, cb := range ep.MethodCallbacks {
if method == r.Method {
body, _ := io.ReadAll(r.Body)
if respData, err := cb(siteacc, r.URL.Query(), body, session); err == nil {
resp.Success = true
resp.Error = ""
resp.Data = respData
} else {
resp.Success = false
resp.Error = fmt.Sprintf("%v", err)
resp.Data = nil
}
}
}
}
// Any failure during query handling results in a bad request
if !resp.Success {
w.WriteHeader(http.StatusBadRequest)
}
// Responses here are always JSON
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
jsonData, _ := json.MarshalIndent(&resp, "", "\t")
_, _ = w.Write(jsonData)
}
func handleList(siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) {
return siteacc.AccountsManager().CloneAccounts(true), nil
}
func handleFind(siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) {
account, err := findAccount(siteacc, values.Get("by"), values.Get("value"))
if err != nil {
return nil, err
}
return map[string]interface{}{"account": account.Clone(true)}, nil
}
func handleCreate(siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) {
account, err := unmarshalRequestData(body)
if err != nil {
return nil, err
}
// Create a new account through the accounts manager
if err := siteacc.AccountsManager().CreateAccount(account); err != nil {
return nil, errors.Wrap(err, "unable to create account")
}
return nil, nil
}
func handleUpdate(siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) {
account, err := unmarshalRequestData(body)
if err != nil {
return nil, err
}
email, setPassword, err := processInvoker(siteacc, values, session)
if err != nil {
return nil, err
}
account.Email = email
// Update the account through the accounts manager
if err := siteacc.AccountsManager().UpdateAccount(account, setPassword, false); err != nil {
return nil, errors.Wrap(err, "unable to update account")
}
return nil, nil
}
func handleConfigure(siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) {
account, err := unmarshalRequestData(body)
if err != nil {
return nil, err
}
email, _, err := processInvoker(siteacc, values, session)
if err != nil {
return nil, err
}
account.Email = email
// Configure the account through the accounts manager
if err := siteacc.AccountsManager().ConfigureAccount(account); err != nil {
return nil, errors.Wrap(err, "unable to configure account")
}
return nil, nil
}
func handleRemove(siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) {
account, err := unmarshalRequestData(body)
if err != nil {
return nil, err
}
// Remove the account through the accounts manager
if err := siteacc.AccountsManager().RemoveAccount(account); err != nil {
return nil, errors.Wrap(err, "unable to remove account")
}
return nil, nil
}
func handleSiteGet(siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) {
siteID := values.Get("site")
if siteID == "" {
return nil, errors.Errorf("no site specified")
}
site := siteacc.SitesManager().FindSite(siteID)
if site == nil {
return nil, errors.Errorf("no site with ID %v exists", siteID)
}
return map[string]interface{}{"site": site.Clone(false)}, nil
}
func handleSiteConfigure(siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) {
email, _, err := processInvoker(siteacc, values, session)
if err != nil {
return nil, err
}
account, err := siteacc.AccountsManager().FindAccount(manager.FindByEmail, email)
if err != nil {
return nil, err
}
siteData := &data.Site{}
if err := json.Unmarshal(body, siteData); err != nil {
return nil, errors.Wrap(err, "invalid form data")
}
siteData.ID = account.Site
// Configure the site through the sites manager
if err := siteacc.SitesManager().UpdateSite(siteData); err != nil {
return nil, errors.Wrap(err, "unable to configure site")
}
return nil, nil
}
func handleLogin(siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) {
account, err := unmarshalRequestData(body)
if err != nil {
return nil, err
}
// Login the user through the users manager
token, err := siteacc.UsersManager().LoginUser(account.Email, account.Password.Value, values.Get("scope"), session)
if err != nil {
return nil, errors.Wrap(err, "unable to login user")
}
return token, nil
}
func handleLogout(siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) {
// Logout the user through the users manager
siteacc.UsersManager().LogoutUser(session)
return nil, nil
}
func handleResetPassword(siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) {
account, err := unmarshalRequestData(body)
if err != nil {
return nil, err
}
// Reset the password through the users manager
if err := siteacc.AccountsManager().ResetPassword(account.Email); err != nil {
return nil, errors.Wrap(err, "unable to reset password")
}
return nil, nil
}
func handleContact(siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) {
if !session.IsUserLoggedIn() {
return nil, errors.Errorf("no user is currently logged in")
}
type jsonData struct {
Subject string `json:"subject"`
Message string `json:"message"`
}
contactData := &jsonData{}
if err := json.Unmarshal(body, contactData); err != nil {
return nil, errors.Wrap(err, "invalid form data")
}
// Send an email through the accounts manager
siteacc.AccountsManager().SendContactForm(session.LoggedInUser().Account, strings.TrimSpace(contactData.Subject), strings.TrimSpace(contactData.Message))
return nil, nil
}
func handleVerifyUserToken(siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) {
token := values.Get("token")
if token == "" {
return nil, errors.Errorf("no token specified")
}
user := values.Get("user")
if user == "" {
return nil, errors.Errorf("no user specified")
}
// Verify the user token using the users manager
newToken, err := siteacc.UsersManager().VerifyUserToken(token, user, values.Get("scope"))
if err != nil {
return nil, errors.Wrap(err, "token verification failed")
}
return newToken, nil
}
func handleDispatchAlert(siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) {
alertsData := &template.Data{}
if err := json.Unmarshal(body, alertsData); err != nil {
return nil, errors.Wrap(err, "unable to unmarshal the alerts data")
}
// Dispatch the alerts using the alerts dispatcher
if err := siteacc.AlertsDispatcher().DispatchAlerts(alertsData, siteacc.AccountsManager().CloneAccounts(true)); err != nil {
return nil, errors.Wrap(err, "error while dispatching the alerts")
}
return nil, nil
}
func handleGrantSiteAccess(siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) {
return handleGrantAccess((*manager.AccountsManager).GrantSiteAccess, siteacc, values, body, session)
}
func handleGrantGOCDBAccess(siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) {
return handleGrantAccess((*manager.AccountsManager).GrantGOCDBAccess, siteacc, values, body, session)
}
func handleGrantAccess(accessSetter accessSetterCallback, siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) {
account, err := unmarshalRequestData(body)
if err != nil {
return nil, err
}
if val := values.Get("status"); len(val) > 0 {
var grantAccess bool
switch strings.ToLower(val) {
case "true":
grantAccess = true
case "false":
grantAccess = false
default:
return nil, errors.Errorf("unsupported access status %v", val[0])
}
// Grant access to the account through the accounts manager
if err := accessSetter(siteacc.AccountsManager(), account, grantAccess); err != nil {
return nil, errors.Wrap(err, "unable to change the access status of the account")
}
} else {
return nil, errors.Errorf("no access status provided")
}
return nil, nil
}
func unmarshalRequestData(body []byte) (*data.Account, error) {
account := &data.Account{}
if err := json.Unmarshal(body, account); err != nil {
return nil, errors.Wrap(err, "invalid account data")
}
account.Cleanup()
return account, nil
}
func findAccount(siteacc *SiteAccounts, by string, value string) (*data.Account, error) {
if len(by) == 0 && len(value) == 0 {
return nil, errors.Errorf("missing search criteria")
}
// Find the account using the accounts manager
account, err := siteacc.AccountsManager().FindAccount(by, value)
if err != nil {
return nil, errors.Wrap(err, "user not found")
}
return account, nil
}
func processInvoker(siteacc *SiteAccounts, values url.Values, session *html.Session) (string, bool, error) {
var email string
var invokedByUser bool
switch strings.ToLower(values.Get("invoker")) {
case invokerUser:
// If this endpoint was called by the user, set the account email from the stored session
if !session.IsUserLoggedIn() {
return "", false, errors.Errorf("no user is currently logged in")
}
email = session.LoggedInUser().Account.Email
invokedByUser = true
default:
return "", false, errors.Errorf("no invoker provided")
}
return email, invokedByUser, nil
}
+170
View File
@@ -0,0 +1,170 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package html
import (
"html/template"
"net/http"
"strings"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/config"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/data"
"github.com/pkg/errors"
"github.com/rs/zerolog"
)
// TemplateID is the type for template identifiers.
type TemplateID = string
// Panel provides basic HTML panel functionality.
type Panel struct {
conf *config.Configuration
log *zerolog.Logger
name string
provider PanelProvider
templates map[TemplateID]*template.Template
}
const (
pathParameterName = "path"
)
func (panel *Panel) initialize(name string, provider PanelProvider, conf *config.Configuration, log *zerolog.Logger) error {
if name == "" {
return errors.Errorf("no name provided")
}
panel.name = name
if conf == nil {
return errors.Errorf("no configuration provided")
}
panel.conf = conf
if log == nil {
return errors.Errorf("no logger provided")
}
panel.log = log
if provider == nil {
return errors.Errorf("no panel provider provided")
}
panel.provider = provider
// Create space for the panel templates
panel.templates = make(map[string]*template.Template, 5)
return nil
}
func (panel *Panel) compile(provider ContentProvider) (string, error) {
content := panelTemplate
// Replace placeholders by the values provided by the content provider
content = strings.ReplaceAll(content, "$(TITLE)", provider.GetTitle())
content = strings.ReplaceAll(content, "$(CAPTION)", provider.GetCaption())
content = strings.ReplaceAll(content, "$(CONTENT_JAVASCRIPT)", provider.GetContentJavaScript())
content = strings.ReplaceAll(content, "$(CONTENT_STYLESHEET)", provider.GetContentStyleSheet())
content = strings.ReplaceAll(content, "$(CONTENT_BODY)", provider.GetContentBody())
return content, nil
}
// AddTemplate adds and compiles a new template.
func (panel *Panel) AddTemplate(name TemplateID, provider ContentProvider) error {
name = panel.getFullTemplateName(name)
if provider == nil {
return errors.Errorf("no content provider provided")
}
content, err := panel.compile(provider)
if err != nil {
return errors.Wrapf(err, "error while compiling panel template %v", name)
}
tpl := template.New(name)
panel.prepareTemplate(tpl)
if _, err := tpl.Parse(content); err != nil {
return errors.Wrapf(err, "error while parsing panel template %v", name)
}
panel.templates[name] = tpl
return nil
}
// Execute generates the HTTP output of the panel and writes it to the response writer.
func (panel *Panel) Execute(w http.ResponseWriter, r *http.Request, session *Session, dataProvider PanelDataProvider) error {
// Get the path query parameter; the panel provider may use this to determine the template to use
path := r.URL.Query().Get(pathParameterName)
actTpl := panel.provider.GetActiveTemplate(session, path)
tplName := panel.getFullTemplateName(actTpl)
tpl, ok := panel.templates[tplName]
if !ok {
return errors.Errorf("template %v not found", tplName)
}
// If a data provider is specified, use it to get additional template data
var data interface{}
if dataProvider != nil {
data = dataProvider(session)
}
// Perform the pre-execution phase in which the panel provider can intercept the actual execution
if state, err := panel.provider.PreExecute(session, actTpl, w, r); err == nil {
if !state {
return nil
}
} else {
return errors.Wrapf(err, "pre-execution of template %v failed", tplName)
}
return tpl.Execute(w, data)
}
func (panel *Panel) prepareTemplate(tpl *template.Template) {
// Add some custom helper functions to the template
tpl.Funcs(template.FuncMap{
"getServerAddress": func() string {
return strings.TrimRight(panel.conf.Webserver.URL, "/")
},
"getSiteName": func(siteID string, fullName bool) string {
siteName, _ := data.QuerySiteName(siteID, fullName, panel.conf.Mentix.URL, panel.conf.Mentix.DataEndpoint)
return siteName
},
})
}
func (panel *Panel) getFullTemplateName(name string) string {
return panel.name + "-" + name
}
// NewPanel creates a new panel.
func NewPanel(name string, provider PanelProvider, conf *config.Configuration, log *zerolog.Logger) (*Panel, error) {
panel := &Panel{}
if err := panel.initialize(name, provider, conf, log); err != nil {
return nil, errors.Wrap(err, "unable to initialize the panel")
}
return panel, nil
}
@@ -0,0 +1,60 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package html
import (
"net/http"
)
const (
// ContinueExecution causes the execution of a panel to continue.
ContinueExecution = true
// AbortExecution causes the execution of a panel to be aborted.
AbortExecution = false
)
// ExecutionResult is the type returned by the PreExecute function of PanelProvider.
type ExecutionResult = bool
// PanelProvider handles general panel tasks.
type PanelProvider interface {
// GetActiveTemplate returns the name of the active template.
GetActiveTemplate(*Session, string) string
// PreExecute is called before the actual template is being executed.
PreExecute(*Session, string, http.ResponseWriter, *http.Request) (ExecutionResult, error)
}
// PanelDataProvider is the function signature for panel data providers.
type PanelDataProvider = func(*Session) interface{}
// ContentProvider defines various methods for HTML content providers.
type ContentProvider interface {
// GetTitle returns the title of the panel.
GetTitle() string
// GetCaption returns the caption which is displayed on the panel.
GetCaption() string
// GetContentJavaScript delivers additional JavaScript code.
GetContentJavaScript() string
// GetContentStyleSheet delivers additional stylesheet code.
GetContentStyleSheet() string
// GetContentBody delivers the actual body content.
GetContentBody() string
}
@@ -0,0 +1,146 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package html
import (
"net/http"
"net/url"
"strings"
"time"
"github.com/google/uuid"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/data"
"github.com/pkg/errors"
)
// Session stores all data associated with an HTML session.
type Session struct {
ID string
MigrationID string
RemoteAddress string
CreationTime time.Time
Timeout time.Duration
Data map[string]interface{}
loggedInUser *SessionUser
expirationTime time.Time
halflifeTime time.Time
sessionCookieName string
}
// SessionUser holds information about the logged in user
type SessionUser struct {
Account *data.Account
Site *data.Site
}
func getRemoteAddress(r *http.Request) string {
// Remove the port number from the remote address
remoteAddress := ""
if address := strings.Split(r.RemoteAddr, ":"); len(address) == 2 {
remoteAddress = address[0]
}
return remoteAddress
}
// LoggedInUser retrieves the currently logged in user or nil if none is logged in.
func (sess *Session) LoggedInUser() *SessionUser {
return sess.loggedInUser
}
// LoginUser logs in the provided user.
func (sess *Session) LoginUser(acc *data.Account, site *data.Site) {
sess.loggedInUser = &SessionUser{
Account: acc,
Site: site,
}
}
// LogoutUser logs out the currently logged in user.
func (sess *Session) LogoutUser() {
sess.loggedInUser = nil
}
// IsUserLoggedIn tells whether a user is currently logged in.
func (sess *Session) IsUserLoggedIn() bool {
return sess.loggedInUser != nil
}
// Save stores the session ID in a cookie using a response writer.
func (sess *Session) Save(cookiePath string, w http.ResponseWriter) {
fullURL, _ := url.Parse(cookiePath)
http.SetCookie(w, &http.Cookie{
Name: sess.sessionCookieName,
Secure: !strings.EqualFold(fullURL.Hostname(), "localhost"),
Value: sess.ID,
MaxAge: int(sess.Timeout / time.Second),
Domain: fullURL.Hostname(),
Path: fullURL.Path,
SameSite: http.SameSiteLaxMode,
})
}
// VerifyRequest checks whether the provided request matches the stored session.
func (sess *Session) VerifyRequest(r *http.Request, verifyRemoteAddress bool) error {
cookie, err := r.Cookie(sess.sessionCookieName)
if err != nil {
return errors.Wrap(err, "unable to retrieve client session ID")
}
if cookie.Value != sess.ID {
return errors.Errorf("the session ID doesn't match")
}
if verifyRemoteAddress && sess.RemoteAddress != "" {
if !strings.EqualFold(getRemoteAddress(r), sess.RemoteAddress) {
return errors.Errorf("remote address has changed (%v != %v)", r.RemoteAddr, sess.RemoteAddress)
}
}
return nil
}
// HalftimePassed checks whether the session has passed the first half of its lifetime.
func (sess *Session) HalftimePassed() bool {
return time.Now().After(sess.halflifeTime)
}
// HasExpired checks whether the session has reached is timeout.
func (sess *Session) HasExpired() bool {
return time.Now().After(sess.expirationTime)
}
// NewSession creates a new session, giving it a random ID.
func NewSession(name string, timeout time.Duration, r *http.Request) *Session {
session := &Session{
ID: uuid.NewString(),
MigrationID: "",
RemoteAddress: getRemoteAddress(r),
CreationTime: time.Now(),
Timeout: timeout,
Data: make(map[string]interface{}, 10),
loggedInUser: nil,
expirationTime: time.Now().Add(timeout),
halflifeTime: time.Now().Add(timeout / 2),
sessionCookieName: name,
}
return session
}
@@ -0,0 +1,191 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package html
import (
"fmt"
"net/http"
"sync"
"time"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/config"
"github.com/pkg/errors"
"github.com/rs/zerolog"
)
// SessionManager manages HTML sessions.
type SessionManager struct {
conf *config.Configuration
log *zerolog.Logger
sessions map[string]*Session
sessionName string
mutex sync.Mutex
}
func (mngr *SessionManager) initialize(name string, conf *config.Configuration, log *zerolog.Logger) error {
if name == "" {
return errors.Errorf("no session name provided")
}
mngr.sessionName = name
if conf == nil {
return errors.Errorf("no configuration provided")
}
mngr.conf = conf
if log == nil {
return errors.Errorf("no logger provided")
}
mngr.log = log
mngr.sessions = make(map[string]*Session, 100)
return nil
}
// HandleRequest performs all session-related tasks during an HTML request. Always returns a valid session object.
func (mngr *SessionManager) HandleRequest(w http.ResponseWriter, r *http.Request) (*Session, error) {
mngr.mutex.Lock()
defer mngr.mutex.Unlock()
var session *Session
var sessionErr error
// Try to get the session ID from the request; if none has been set yet, a new one will be assigned
cookie, err := r.Cookie(mngr.sessionName)
if err == nil {
session = mngr.findSession(cookie.Value)
if session != nil {
mngr.logSessionInfo(session, r, "existing session found")
// Verify the request against the session: If it is invalid, set an error; if the session has expired, create a new one; if it has already passed its halftime, migrate to a new one
if err := session.VerifyRequest(r, mngr.conf.Webserver.VerifyRemoteAddress); err == nil {
if session.HasExpired() {
// The session has expired, so a new one needs to be created
session = nil
mngr.logSessionInfo(session, r, "session expired")
} else if session.HalftimePassed() {
// The session has passed its halftime, so migrate it to a new one (makes hijacking session IDs harder)
session, err = mngr.migrateSession(session, r)
if err != nil {
session = nil
sessionErr = errors.Wrap(err, "unable to migrate session")
}
mngr.logSessionInfo(session, r, "session migrated")
}
} else {
session = nil
sessionErr = errors.Wrap(err, "invalid session")
mngr.logSessionInfo(session, r, "session invalid (verify failed)")
}
}
} else if err != http.ErrNoCookie {
// The session cookie exists but seems to be invalid, so set an error
session = nil
sessionErr = errors.Wrap(err, "unable to get the session ID from the client")
mngr.logSessionInfo(session, r, fmt.Sprintf("session cookie error: %v", err))
}
if session == nil {
// No session found for the client, so create a new one; this will always succeed
session = mngr.createSession(r)
mngr.logSessionInfo(session, r, "assigned new session")
}
// Store the session ID on the client side
session.Save(mngr.conf.Webserver.URL, w)
return session, sessionErr
}
// PurgeSessions removes any expired sessions.
func (mngr *SessionManager) PurgeSessions() {
mngr.mutex.Lock()
defer mngr.mutex.Unlock()
var expiredSessions []string
for id, session := range mngr.sessions {
if session.HasExpired() {
expiredSessions = append(expiredSessions, id)
}
}
for _, id := range expiredSessions {
delete(mngr.sessions, id)
}
}
func (mngr *SessionManager) createSession(r *http.Request) *Session {
session := NewSession(mngr.sessionName, time.Duration(mngr.conf.Webserver.SessionTimeout)*time.Second, r)
mngr.sessions[session.ID] = session
return session
}
func (mngr *SessionManager) findSession(id string) *Session {
if session, ok := mngr.sessions[id]; ok {
return session
}
return nil
}
func (mngr *SessionManager) migrateSession(session *Session, r *http.Request) (*Session, error) {
sessionNew := mngr.createSession(r)
// Carry over the old session information, thus preserving the existing session
sessionNew.MigrationID = session.ID
sessionNew.Data = session.Data
if user := session.LoggedInUser(); user != nil {
sessionNew.LoginUser(user.Account, user.Site)
} else {
sessionNew.LogoutUser()
}
// Delete the old session
delete(mngr.sessions, session.ID)
return sessionNew, nil
}
func (mngr *SessionManager) logSessionInfo(session *Session, r *http.Request, info string) {
if mngr.conf.Webserver.LogSessions {
if session != nil {
mngr.log.Debug().Str("id", session.ID).Str("address", r.RemoteAddr).Str("path", r.URL.Path).Msg(info)
} else {
mngr.log.Debug().Str("address", r.RemoteAddr).Str("path", r.URL.Path).Msg(info)
}
}
}
// NewSessionManager creates a new session manager.
func NewSessionManager(name string, conf *config.Configuration, log *zerolog.Logger) (*SessionManager, error) {
mngr := &SessionManager{}
if err := mngr.initialize(name, conf, log); err != nil {
return nil, errors.Wrap(err, "unable to initialize the session manager")
}
return mngr, nil
}
@@ -0,0 +1,170 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package html
const panelTemplate = `
<!DOCTYPE html>
<html>
<head>
<script>
const STATE_NONE = 0
const STATE_STATUS = 1
const STATE_SUCCESS = 2
const STATE_ERROR = 3
function enableForm(id, enable) {
var form = document.getElementById(id);
var elements = form.elements;
for (var i = 0, len = elements.length; i < len; ++i) {
elements[i].disabled = !enable;
}
}
function setElementVisibility(id, visible) {
var elem = document.getElementById(id);
if (visible) {
elem.classList.add("visible");
elem.classList.remove("hidden");
} else {
elem.classList.remove("visible");
elem.classList.add("hidden");
}
}
function setState(state, msg = "", formId = null, focusElem = null, formState = null) {
setElementVisibility("status", state == STATE_STATUS);
setElementVisibility("success", state == STATE_SUCCESS);
setElementVisibility("error", state == STATE_ERROR);
var elem = null;
switch (state) {
case STATE_STATUS:
elem = document.getElementById("status");
break;
case STATE_SUCCESS:
elem = document.getElementById("success");
break;
case STATE_ERROR:
elem = document.getElementById("error");
break;
}
if (elem !== null) {
elem.innerHTML = msg;
}
if (formId !== null && formState !== null) {
enableForm(formId, formState);
}
if (focusElem !== null) {
var elem = document.getElementById(focusElem);
elem.focus();
}
}
FormData.prototype.getTrimmed = function(id) {
var val = this.get(id);
if (val != null) {
return val.trim();
} else {
return "";
}
}
$(CONTENT_JAVASCRIPT)
</script>
<style>
form {
border-color: lightgray !important;
}
button {
min-width: 140px;
}
input {
width: 95%;
}
label {
font-weight: bold;
}
h1 {
text-align: center;
}
.box {
width: 100%;
border: 1px solid black;
border-radius: 10px;
padding: 10px;
}
.container {
width: 900px;
display: grid;
grid-gap: 10px;
position: absolute;
left: 50%;
transform: translate(-50%, 0);
}
.container-inline {
display: inline-grid;
grid-gap: 10px;
}
.status {
border-color: #F7B22A;
background: #FFEABF;
}
.success {
border-color: #3CAC3A;
background: #D3EFD2;
}
.error {
border-color: #F20000;
background: #F4D0D0;
}
.visible {
display: block;
}
.hidden {
display: none;
}
$(CONTENT_STYLESHEET)
</style>
<title>$(TITLE)</title>
</head>
<body>
<div class="container">
<div><h1>$(CAPTION)</h1></div>
$(CONTENT_BODY)
<div id="status" class="box status hidden">
</div>
<div id="success" class="box success hidden">
</div>
<div id="error" class="box error hidden">
</div>
</div>
</body>
</html>
`
@@ -0,0 +1,34 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package manager
import "github.com/opencloud-eu/reva/v2/pkg/siteacc/data"
// AccountsListenerCallback is the generic function type for accounts listeners.
type AccountsListenerCallback = func(AccountsListener, *data.Account)
// AccountsListener is an interface that listens to accounts events.
type AccountsListener interface {
// AccountCreated is called whenever an account was created.
AccountCreated(account *data.Account)
// AccountUpdated is called whenever an account was updated.
AccountUpdated(account *data.Account)
// AccountRemoved is called whenever an account was removed.
AccountRemoved(account *data.Account)
}
@@ -0,0 +1,343 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package manager
import (
"strings"
"sync"
"time"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/config"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/data"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/email"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/manager/gocdb"
"github.com/opencloud-eu/reva/v2/pkg/smtpclient"
"github.com/pkg/errors"
"github.com/rs/zerolog"
"github.com/sethvargo/go-password/password"
)
const (
// FindByEmail holds the string value of the corresponding search criterium.
FindByEmail = "email"
)
// AccountsManager is responsible for all site account related tasks.
type AccountsManager struct {
conf *config.Configuration
log *zerolog.Logger
storage data.Storage
accounts data.Accounts
accountsListeners []AccountsListener
smtp *smtpclient.SMTPCredentials
mutex sync.RWMutex
}
func (mngr *AccountsManager) initialize(storage data.Storage, conf *config.Configuration, log *zerolog.Logger) error {
if conf == nil {
return errors.Errorf("no configuration provided")
}
mngr.conf = conf
if log == nil {
return errors.Errorf("no logger provided")
}
mngr.log = log
if storage == nil {
return errors.Errorf("no storage provided")
}
mngr.storage = storage
mngr.accounts = make(data.Accounts, 0, 32) // Reserve some space for accounts
mngr.readAllAccounts()
// Register accounts listeners
if listener, err := gocdb.NewListener(mngr.conf, mngr.log); err == nil {
mngr.accountsListeners = append(mngr.accountsListeners, listener)
} else {
return errors.Wrap(err, "unable to create the GOCDB accounts listener")
}
// Create the SMTP client
if conf.Email.SMTP != nil {
mngr.smtp = smtpclient.NewSMTPCredentials(conf.Email.SMTP)
}
return nil
}
func (mngr *AccountsManager) readAllAccounts() {
if accounts, err := mngr.storage.ReadAccounts(); err == nil {
mngr.accounts = *accounts
} else {
// Just warn when not being able to read accounts
mngr.log.Warn().Err(err).Msg("error while reading accounts")
}
}
func (mngr *AccountsManager) writeAllAccounts() {
if err := mngr.storage.WriteAccounts(&mngr.accounts); err != nil {
// Just warn when not being able to write accounts
mngr.log.Warn().Err(err).Msg("error while writing accounts")
}
}
func (mngr *AccountsManager) findAccount(by string, value string) (*data.Account, error) {
if len(value) == 0 {
return nil, errors.Errorf("no search value specified")
}
var account *data.Account
switch strings.ToLower(by) {
case FindByEmail:
account = mngr.findAccountByPredicate(func(account *data.Account) bool { return strings.EqualFold(account.Email, value) })
default:
return nil, errors.Errorf("invalid search type %v", by)
}
if account != nil {
return account, nil
}
return nil, errors.Errorf("no user found matching the specified criteria")
}
func (mngr *AccountsManager) findAccountByPredicate(predicate func(*data.Account) bool) *data.Account {
for _, account := range mngr.accounts {
if predicate(account) {
return account
}
}
return nil
}
// CreateAccount creates a new account; if an account with the same email address already exists, an error is returned.
func (mngr *AccountsManager) CreateAccount(accountData *data.Account) error {
mngr.mutex.Lock()
defer mngr.mutex.Unlock()
// Accounts must be unique (identified by their email address)
if account, _ := mngr.findAccount(FindByEmail, accountData.Email); account != nil {
return errors.Errorf("an account with the specified email address already exists")
}
if account, err := data.NewAccount(accountData.Email, accountData.Title, accountData.FirstName, accountData.LastName, accountData.Site, accountData.Role, accountData.PhoneNumber, accountData.Password.Value); err == nil {
mngr.accounts = append(mngr.accounts, account)
mngr.storage.AccountAdded(account)
mngr.writeAllAccounts()
mngr.sendEmail(account, nil, email.SendAccountCreated)
mngr.callListeners(account, AccountsListener.AccountCreated)
} else {
return errors.Wrap(err, "error while creating account")
}
return nil
}
// UpdateAccount updates the account identified by the account email; if no such account exists, an error is returned.
func (mngr *AccountsManager) UpdateAccount(accountData *data.Account, setPassword bool, copyData bool) error {
mngr.mutex.Lock()
defer mngr.mutex.Unlock()
account, err := mngr.findAccount(FindByEmail, accountData.Email)
if err != nil {
return errors.Wrap(err, "user to update not found")
}
if err := account.Update(accountData, setPassword, copyData); err == nil {
account.DateModified = time.Now()
mngr.storage.AccountUpdated(account)
mngr.writeAllAccounts()
mngr.callListeners(account, AccountsListener.AccountUpdated)
} else {
return errors.Wrap(err, "error while updating account")
}
return nil
}
// ConfigureAccount configures the account identified by the account email; if no such account exists, an error is returned.
func (mngr *AccountsManager) ConfigureAccount(accountData *data.Account) error {
mngr.mutex.Lock()
defer mngr.mutex.Unlock()
account, err := mngr.findAccount(FindByEmail, accountData.Email)
if err != nil {
return errors.Wrap(err, "user to configure not found")
}
if err := account.Configure(accountData); err == nil {
account.DateModified = time.Now()
mngr.storage.AccountUpdated(account)
mngr.writeAllAccounts()
mngr.callListeners(account, AccountsListener.AccountUpdated)
} else {
return errors.Wrap(err, "error while configuring account")
}
return nil
}
// ResetPassword resets the password for the given user.
func (mngr *AccountsManager) ResetPassword(name string) error {
account, err := mngr.findAccount(FindByEmail, name)
if err != nil {
return errors.Wrap(err, "user to reset password for not found")
}
accountUpd := account.Clone(true)
accountUpd.Password.Value = password.MustGenerate(defaultPasswordLength, 2, 0, false, true)
err = mngr.UpdateAccount(accountUpd, true, false)
if err == nil {
mngr.sendEmail(accountUpd, nil, email.SendPasswordReset)
}
return err
}
// FindAccount is used to find an account by various criteria. The account is cloned to prevent data changes.
func (mngr *AccountsManager) FindAccount(by string, value string) (*data.Account, error) {
return mngr.FindAccountEx(by, value, true)
}
// FindAccountEx is used to find an account by various criteria and optionally clone the account.
func (mngr *AccountsManager) FindAccountEx(by string, value string, cloneAccount bool) (*data.Account, error) {
mngr.mutex.RLock()
defer mngr.mutex.RUnlock()
account, err := mngr.findAccount(by, value)
if err != nil {
return nil, err
}
if cloneAccount {
account = account.Clone(false)
}
return account, nil
}
// GrantSiteAccess sets the Site access status of the account identified by the account email; if no such account exists, an error is returned.
func (mngr *AccountsManager) GrantSiteAccess(accountData *data.Account, grantAccess bool) error {
mngr.mutex.Lock()
defer mngr.mutex.Unlock()
account, err := mngr.findAccount(FindByEmail, accountData.Email)
if err != nil {
return errors.Wrap(err, "no account with the specified email exists")
}
return mngr.grantAccess(account, &account.Data.SiteAccess, grantAccess, email.SendSiteAccessGranted)
}
// GrantGOCDBAccess sets the GOCDB access status of the account identified by the account email; if no such account exists, an error is returned.
func (mngr *AccountsManager) GrantGOCDBAccess(accountData *data.Account, grantAccess bool) error {
mngr.mutex.Lock()
defer mngr.mutex.Unlock()
account, err := mngr.findAccount(FindByEmail, accountData.Email)
if err != nil {
return errors.Wrap(err, "no account with the specified email exists")
}
return mngr.grantAccess(account, &account.Data.GOCDBAccess, grantAccess, email.SendGOCDBAccessGranted)
}
// RemoveAccount removes the account identified by the account email; if no such account exists, an error is returned.
func (mngr *AccountsManager) RemoveAccount(accountData *data.Account) error {
mngr.mutex.Lock()
defer mngr.mutex.Unlock()
for i, account := range mngr.accounts {
if strings.EqualFold(account.Email, accountData.Email) {
mngr.accounts = append(mngr.accounts[:i], mngr.accounts[i+1:]...)
mngr.storage.AccountRemoved(account)
mngr.writeAllAccounts()
mngr.callListeners(account, AccountsListener.AccountRemoved)
return nil
}
}
return errors.Errorf("no account with the specified email exists")
}
// SendContactForm sends a generic email to the ScienceMesh admins.
func (mngr *AccountsManager) SendContactForm(account *data.Account, subject, message string) {
mngr.sendEmail(account, map[string]string{"Subject": subject, "Message": message}, email.SendContactForm)
}
// CloneAccounts retrieves all accounts currently stored by cloning the data, thus avoiding race conflicts and making outside modifications impossible.
func (mngr *AccountsManager) CloneAccounts(erasePasswords bool) data.Accounts {
mngr.mutex.RLock()
defer mngr.mutex.RUnlock()
clones := make(data.Accounts, 0, len(mngr.accounts))
for _, acc := range mngr.accounts {
clones = append(clones, acc.Clone(erasePasswords))
}
return clones
}
func (mngr *AccountsManager) grantAccess(account *data.Account, accessFlag *bool, grantAccess bool, emailFunc email.SendFunction) error {
accessOld := *accessFlag
*accessFlag = grantAccess
mngr.storage.AccountUpdated(account)
mngr.writeAllAccounts()
if *accessFlag && *accessFlag != accessOld {
mngr.sendEmail(account, nil, emailFunc)
}
mngr.callListeners(account, AccountsListener.AccountUpdated)
return nil
}
func (mngr *AccountsManager) callListeners(account *data.Account, cb AccountsListenerCallback) {
for _, listener := range mngr.accountsListeners {
cb(listener, account)
}
}
func (mngr *AccountsManager) sendEmail(account *data.Account, params map[string]string, sendFunc email.SendFunction) {
_ = sendFunc(account, []string{account.Email, mngr.conf.Email.NotificationsMail}, params, *mngr.conf)
}
// NewAccountsManager creates a new accounts manager instance.
func NewAccountsManager(storage data.Storage, conf *config.Configuration, log *zerolog.Logger) (*AccountsManager, error) {
mngr := &AccountsManager{}
if err := mngr.initialize(storage, conf, log); err != nil {
return nil, errors.Wrap(err, "unable to initialize the accounts manager")
}
return mngr, nil
}
@@ -0,0 +1,97 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package gocdb
import (
"bytes"
"encoding/json"
"io"
"net/http"
"github.com/opencloud-eu/reva/v2/pkg/mentix/utils/network"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/data"
"github.com/pkg/errors"
)
const (
opCreateOrUpdate = "CreateOrUpdate"
opDelete = "Delete"
)
type writeAccountUserData struct {
Email string `json:"Email"`
FirstName string `json:"FirstName"`
LastName string `json:"LastName"`
PhoneNumber string `json:"PhoneNumber"`
}
type writeAccountData struct {
APIKey string `json:"APIKey"`
Operation string `json:"Operation"`
Data writeAccountUserData `json:"Data"`
}
func writeAccount(account *data.Account, operation string, address string, apiKey string) error {
// Fill in the data to send
userData := getWriteAccountData(account)
userData.APIKey = apiKey
userData.Operation = operation
// Send the data to the GOCDB endpoint
endpointURL, err := network.GenerateURL(address, "/ext/v1/user", network.URLParams{})
if err != nil {
return errors.Wrap(err, "unable to generate the GOCDB URL")
}
jsonData, err := json.Marshal(userData)
if err != nil {
return errors.Wrap(err, "unable to marshal the user data")
}
req, err := http.NewRequest(http.MethodPost, endpointURL.String(), bytes.NewReader(jsonData))
if err != nil {
return errors.Wrap(err, "unable to create HTTP request")
}
req.Header.Set("Content-Type", "application/json; charset=UTF-8")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return errors.Wrap(err, "unable to send data to endpoint")
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
msg, _ := io.ReadAll(resp.Body)
return errors.Errorf("unable to perform request: %v", string(msg))
}
return nil
}
func getWriteAccountData(account *data.Account) *writeAccountData {
return &writeAccountData{
Data: writeAccountUserData{
Email: account.Email,
FirstName: account.FirstName,
LastName: account.LastName,
PhoneNumber: account.PhoneNumber,
},
}
}
@@ -0,0 +1,81 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package gocdb
import (
"github.com/opencloud-eu/reva/v2/pkg/siteacc/config"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/data"
"github.com/pkg/errors"
"github.com/rs/zerolog"
)
// AccountsListener is the GOCDB accounts listener.
type AccountsListener struct {
conf *config.Configuration
log *zerolog.Logger
}
func (listener *AccountsListener) initialize(conf *config.Configuration, log *zerolog.Logger) error {
if conf == nil {
return errors.Errorf("no configuration provided")
}
listener.conf = conf
if log == nil {
return errors.Errorf("no logger provided")
}
listener.log = log
return nil
}
// AccountCreated is called whenever an account was created.
func (listener *AccountsListener) AccountCreated(account *data.Account) {
listener.updateGOCDB(account, false)
}
// AccountUpdated is called whenever an account was updated.
func (listener *AccountsListener) AccountUpdated(account *data.Account) {
listener.updateGOCDB(account, false)
}
// AccountRemoved is called whenever an account was removed.
func (listener *AccountsListener) AccountRemoved(account *data.Account) {
listener.updateGOCDB(account, true)
}
func (listener *AccountsListener) updateGOCDB(account *data.Account, forceRemoval bool) {
if account != nil && account.Data.GOCDBAccess && !forceRemoval {
if err := writeAccount(account, opCreateOrUpdate, listener.conf.GOCDB.WriteURL, listener.conf.GOCDB.APIKey); err != nil {
listener.log.Err(err).Str("userid", account.Email).Msg("unable to update GOCDB account")
}
} else {
// Errors while deleting an account are ignored (account might not exist at all, for example)
_ = writeAccount(account, opDelete, listener.conf.GOCDB.WriteURL, listener.conf.GOCDB.APIKey)
}
}
// NewListener creates a new GOCDB accounts listener.
func NewListener(conf *config.Configuration, log *zerolog.Logger) (*AccountsListener, error) {
listener := &AccountsListener{}
if err := listener.initialize(conf, log); err != nil {
return nil, errors.Wrap(err, "unable to initialize the GOCDB accounts listener")
}
return listener, nil
}
@@ -0,0 +1,185 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package manager
import (
"strings"
"sync"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/config"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/data"
"github.com/pkg/errors"
"github.com/rs/zerolog"
)
// SitesManager is responsible for all sites related tasks.
type SitesManager struct {
conf *config.Configuration
log *zerolog.Logger
storage data.Storage
sites data.Sites
mutex sync.RWMutex
}
func (mngr *SitesManager) initialize(storage data.Storage, conf *config.Configuration, log *zerolog.Logger) error {
if conf == nil {
return errors.Errorf("no configuration provided")
}
mngr.conf = conf
if log == nil {
return errors.Errorf("no logger provided")
}
mngr.log = log
if storage == nil {
return errors.Errorf("no storage provided")
}
mngr.storage = storage
mngr.sites = make(data.Sites, 0, 32) // Reserve some space for sites
mngr.readAllSites()
return nil
}
func (mngr *SitesManager) readAllSites() {
if sites, err := mngr.storage.ReadSites(); err == nil {
mngr.sites = *sites
} else {
// Just warn when not being able to read sites
mngr.log.Warn().Err(err).Msg("error while reading sites")
}
}
func (mngr *SitesManager) writeAllSites() {
if err := mngr.storage.WriteSites(&mngr.sites); err != nil {
// Just warn when not being able to write sites
mngr.log.Warn().Err(err).Msg("error while writing sites")
}
}
// GetSite retrieves the site with the given ID, creating it first if necessary.
func (mngr *SitesManager) GetSite(id string, cloneSite bool) (*data.Site, error) {
mngr.mutex.RLock()
defer mngr.mutex.RUnlock()
site, err := mngr.getSite(id)
if err != nil {
return nil, err
}
if cloneSite {
site = site.Clone(false)
}
return site, nil
}
// FindSite returns the site specified by the ID if one exists.
func (mngr *SitesManager) FindSite(id string) *data.Site {
site, _ := mngr.findSite(id)
return site
}
// UpdateSite updates the site identified by the site ID; if no such site exists, one will be created first.
func (mngr *SitesManager) UpdateSite(siteData *data.Site) error {
mngr.mutex.Lock()
defer mngr.mutex.Unlock()
site, err := mngr.getSite(siteData.ID)
if err != nil {
return errors.Wrap(err, "site to update not found")
}
if err := site.Update(siteData, mngr.conf.Security.CredentialsPassphrase); err == nil {
mngr.storage.SiteUpdated(site)
mngr.writeAllSites()
} else {
return errors.Wrap(err, "error while updating site")
}
return nil
}
// CloneSites retrieves all sites currently stored by cloning the data, thus avoiding race conflicts and making outside modifications impossible.
func (mngr *SitesManager) CloneSites(eraseCredentials bool) data.Sites {
mngr.mutex.RLock()
defer mngr.mutex.RUnlock()
clones := make(data.Sites, 0, len(mngr.sites))
for _, site := range mngr.sites {
clones = append(clones, site.Clone(eraseCredentials))
}
return clones
}
func (mngr *SitesManager) getSite(id string) (*data.Site, error) {
site, err := mngr.findSite(id)
if site == nil {
site, err = mngr.createSite(id)
}
return site, err
}
func (mngr *SitesManager) createSite(id string) (*data.Site, error) {
site, err := data.NewSite(id)
if err != nil {
return nil, errors.Wrap(err, "error while creating site")
}
mngr.sites = append(mngr.sites, site)
mngr.storage.SiteAdded(site)
mngr.writeAllSites()
return site, nil
}
func (mngr *SitesManager) findSite(id string) (*data.Site, error) {
if len(id) == 0 {
return nil, errors.Errorf("no search ID specified")
}
site := mngr.findSiteByPredicate(func(site *data.Site) bool { return strings.EqualFold(site.ID, id) })
if site != nil {
return site, nil
}
return nil, errors.Errorf("no site found matching the specified ID")
}
func (mngr *SitesManager) findSiteByPredicate(predicate func(*data.Site) bool) *data.Site {
for _, site := range mngr.sites {
if predicate(site) {
return site
}
}
return nil
}
// NewSitesManager creates a new sites manager instance.
func NewSitesManager(storage data.Storage, conf *config.Configuration, log *zerolog.Logger) (*SitesManager, error) {
mngr := &SitesManager{}
if err := mngr.initialize(storage, conf, log); err != nil {
return nil, errors.Wrap(err, "unable to initialize the sites manager")
}
return mngr, nil
}
@@ -0,0 +1,87 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package manager
import (
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/pkg/errors"
"github.com/sethvargo/go-password/password"
)
type userToken struct {
jwt.RegisteredClaims
User string `json:"user"`
Scope string `json:"scope"`
}
const (
tokenKeyLength = 16
tokenIssuer = "sciencemesh_siteacc"
)
var (
tokenSecret string
)
func generateUserToken(user string, scope string, timeout int) (string, error) {
// Create a JWT as the user token
claims := userToken{
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(timeout) * time.Second)),
Issuer: tokenIssuer,
IssuedAt: jwt.NewNumericDate(time.Now()),
},
User: user,
Scope: scope,
}
token := jwt.NewWithClaims(jwt.GetSigningMethod("HS256"), claims)
signedToken, err := token.SignedString([]byte(tokenSecret))
if err != nil {
return "", errors.Wrapf(err, "error signing token with claims %+v", claims)
}
return signedToken, nil
}
func extractUserToken(token string) (*userToken, error) {
// Parse the token and try to extract the claims
parsedToken, err := jwt.ParseWithClaims(token, &userToken{}, func(token *jwt.Token) (interface{}, error) { return []byte(tokenSecret), nil })
if err != nil {
return nil, errors.Wrap(err, "error parsing token")
}
if claims, ok := parsedToken.Claims.(*userToken); ok && parsedToken.Valid {
if claims.Issuer != tokenIssuer {
return nil, errors.Errorf("invalid token issuer")
}
return claims, nil
}
return nil, errors.Errorf("invalid token")
}
func init() {
// Generate the token secret randomly
tokenSecret = password.MustGenerate(tokenKeyLength, tokenKeyLength/4, tokenKeyLength/4, false, true)
}
@@ -0,0 +1,150 @@
// Copyright 2018-2020 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package manager
import (
"strings"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/config"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/html"
"github.com/pkg/errors"
"github.com/rs/zerolog"
)
// UsersManager is responsible for managing logged in users through session objects.
type UsersManager struct {
conf *config.Configuration
log *zerolog.Logger
sitesManager *SitesManager
accountsManager *AccountsManager
}
const (
defaultPasswordLength = 12
)
func (mngr *UsersManager) initialize(conf *config.Configuration, log *zerolog.Logger, sitesManager *SitesManager, accountsManager *AccountsManager) error {
if conf == nil {
return errors.Errorf("no configuration provided")
}
mngr.conf = conf
if log == nil {
return errors.Errorf("no logger provided")
}
mngr.log = log
if sitesManager == nil {
return errors.Errorf("no sites manager provided")
}
mngr.sitesManager = sitesManager
if accountsManager == nil {
return errors.Errorf("no accounts manager provided")
}
mngr.accountsManager = accountsManager
return nil
}
// LoginUser tries to login a given username/password pair. On success, the corresponding user account is stored in the session and a user token is returned.
func (mngr *UsersManager) LoginUser(name, password string, scope string, session *html.Session) (string, error) {
account, err := mngr.accountsManager.FindAccountEx(FindByEmail, name, false)
if err != nil {
return "", errors.Wrap(err, "no account with the specified email exists")
}
// Verify the provided password
if !account.Password.Compare(password) {
return "", errors.Errorf("invalid password")
}
// Check if the user has access to the specified scope
if !account.CheckScopeAccess(scope) {
return "", errors.Errorf("no access to the specified scope granted")
}
// Get the site the account belongs to
site, err := mngr.sitesManager.GetSite(account.Site, false)
if err != nil {
return "", errors.Wrap(err, "no site with the specified ID exists")
}
// Store the user account in the session
session.LoginUser(account, site)
// Generate a token that can be used as a "ticket"
token, err := generateUserToken(session.LoggedInUser().Account.Email, scope, mngr.conf.Webserver.SessionTimeout)
if err != nil {
return "", errors.Wrap(err, "unable to generate user token")
}
return token, nil
}
// LogoutUser logs the current user out.
func (mngr *UsersManager) LogoutUser(session *html.Session) {
// Just unset the user account stored in the session
session.LogoutUser()
}
// VerifyUserToken is used to verify a user token against the current session.
func (mngr *UsersManager) VerifyUserToken(token string, user string, scope string) (string, error) {
// Verify the token by trying to extract it
utoken, err := extractUserToken(token)
if err != nil {
return "", errors.Wrap(err, "unable to verify user token")
}
// Check the provided email against the stored one
if !strings.EqualFold(utoken.User, user) {
return "", errors.Errorf("mismatching user")
}
// Check if the user account actually exists and has proper scope access
if strings.EqualFold(scope, utoken.Scope) {
if acc, err := mngr.accountsManager.FindAccount(FindByEmail, utoken.User); err == nil {
if !acc.CheckScopeAccess(scope) {
return "", errors.Errorf("no scope access")
}
} else {
return "", errors.Errorf("invalid email")
}
} else {
return "", errors.Errorf("invalid scope")
}
// Refresh the user token (as a form of keep-alive, since tokens expire quickly)
newToken, err := generateUserToken(utoken.User, utoken.Scope, mngr.conf.Webserver.SessionTimeout)
if err != nil {
return "", errors.Wrap(err, "unable to refresh user token")
}
return newToken, nil
}
// NewUsersManager creates a new users manager instance.
func NewUsersManager(conf *config.Configuration, log *zerolog.Logger, sitesManager *SitesManager, accountsManager *AccountsManager) (*UsersManager, error) {
mngr := &UsersManager{}
if err := mngr.initialize(conf, log, sitesManager, accountsManager); err != nil {
return nil, errors.Wrap(err, "unable to initialize the users manager")
}
return mngr, nil
}
+215
View File
@@ -0,0 +1,215 @@
// Copyright 2018-2021 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package siteacc
import (
"fmt"
"net/http"
accpanel "github.com/opencloud-eu/reva/v2/pkg/siteacc/account"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/admin"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/alerting"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/config"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/data"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/html"
"github.com/opencloud-eu/reva/v2/pkg/siteacc/manager"
"github.com/pkg/errors"
"github.com/rs/zerolog"
)
// SiteAccounts represents the main Site Accounts service object.
type SiteAccounts struct {
conf *config.Configuration
log *zerolog.Logger
sessions *html.SessionManager
storage data.Storage
sitesManager *manager.SitesManager
accountsManager *manager.AccountsManager
usersManager *manager.UsersManager
alertsDispatcher *alerting.Dispatcher
adminPanel *admin.Panel
accountPanel *accpanel.Panel
}
func (siteacc *SiteAccounts) initialize(conf *config.Configuration, log *zerolog.Logger) error {
if conf == nil {
return fmt.Errorf("no configuration provided")
}
siteacc.conf = conf
if log == nil {
return fmt.Errorf("no logger provided")
}
siteacc.log = log
// Create the session mananger
sessions, err := html.NewSessionManager("siteacc_session", conf, log)
if err != nil {
return errors.Wrap(err, "error while creating the session manager")
}
siteacc.sessions = sessions
// Create the central storage
storage, err := siteacc.createStorage(conf.Storage.Driver)
if err != nil {
return errors.Wrap(err, "unable to create storage")
}
siteacc.storage = storage
// Create the sites manager instance
smngr, err := manager.NewSitesManager(storage, conf, log)
if err != nil {
return errors.Wrap(err, "error creating the sites manager")
}
siteacc.sitesManager = smngr
// Create the accounts manager instance
amngr, err := manager.NewAccountsManager(storage, conf, log)
if err != nil {
return errors.Wrap(err, "error creating the accounts manager")
}
siteacc.accountsManager = amngr
// Create the users manager instance
umngr, err := manager.NewUsersManager(conf, log, siteacc.sitesManager, siteacc.accountsManager)
if err != nil {
return errors.Wrap(err, "error creating the users manager")
}
siteacc.usersManager = umngr
// Create the alerts dispatcher instance
dispatcher, err := alerting.NewDispatcher(conf, log)
if err != nil {
return errors.Wrap(err, "error creating the alerts dispatcher")
}
siteacc.alertsDispatcher = dispatcher
// Create the admin panel
if pnl, err := admin.NewPanel(conf, log); err == nil {
siteacc.adminPanel = pnl
} else {
return errors.Wrap(err, "unable to create the administration panel")
}
// Create the account panel
if pnl, err := accpanel.NewPanel(conf, log); err == nil {
siteacc.accountPanel = pnl
} else {
return errors.Wrap(err, "unable to create the account panel")
}
return nil
}
// RequestHandler returns the HTTP request handler of the service.
func (siteacc *SiteAccounts) RequestHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
// Get the active session for the request (or create a new one); a valid session object will always be returned
siteacc.sessions.PurgeSessions() // Remove expired sessions first
session, err := siteacc.sessions.HandleRequest(w, r)
if err != nil {
siteacc.log.Err(err).Msg("an error occurred while handling sessions")
}
epHandled := false
for _, ep := range getEndpoints() {
if ep.Path == r.URL.Path {
ep.Handler(siteacc, ep, w, r, session)
epHandled = true
break
}
}
if !epHandled {
w.WriteHeader(http.StatusBadRequest)
_, _ = fmt.Fprintf(w, "Unknown endpoint %v", r.URL.Path)
}
})
}
// ShowAdministrationPanel writes the administration panel HTTP output directly to the response writer.
func (siteacc *SiteAccounts) ShowAdministrationPanel(w http.ResponseWriter, r *http.Request, session *html.Session) error {
// The admin panel only shows the stored accounts and offers actions through links, so let it use cloned data
accounts := siteacc.accountsManager.CloneAccounts(true)
return siteacc.adminPanel.Execute(w, r, session, &accounts)
}
// ShowAccountPanel writes the account panel HTTP output directly to the response writer.
func (siteacc *SiteAccounts) ShowAccountPanel(w http.ResponseWriter, r *http.Request, session *html.Session) error {
return siteacc.accountPanel.Execute(w, r, session)
}
// SitesManager returns the central sites manager instance.
func (siteacc *SiteAccounts) SitesManager() *manager.SitesManager {
return siteacc.sitesManager
}
// AccountsManager returns the central accounts manager instance.
func (siteacc *SiteAccounts) AccountsManager() *manager.AccountsManager {
return siteacc.accountsManager
}
// UsersManager returns the central users manager instance.
func (siteacc *SiteAccounts) UsersManager() *manager.UsersManager {
return siteacc.usersManager
}
// AlertsDispatcher returns the central alerts dispatcher instance.
func (siteacc *SiteAccounts) AlertsDispatcher() *alerting.Dispatcher {
return siteacc.alertsDispatcher
}
// GetPublicEndpoints returns a list of all public endpoints.
func (siteacc *SiteAccounts) GetPublicEndpoints() []string {
// TODO: Only for local testing!
// return []string{"/"}
endpoints := make([]string, 0, 5)
for _, ep := range getEndpoints() {
if ep.IsPublic {
endpoints = append(endpoints, ep.Path)
}
}
return endpoints
}
func (siteacc *SiteAccounts) createStorage(driver string) (data.Storage, error) {
if driver == "file" {
return data.NewFileStorage(siteacc.conf, siteacc.log)
}
return nil, errors.Errorf("unknown storage driver %v", driver)
}
// New returns a new Site Accounts service instance.
func New(conf *config.Configuration, log *zerolog.Logger) (*SiteAccounts, error) {
// Configure the accounts service
siteacc := new(SiteAccounts)
if err := siteacc.initialize(conf, log); err != nil {
return nil, fmt.Errorf("unable to initialize site accounts: %v", err)
}
return siteacc, nil
}