mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2025-07-19 03:11:40 +02:00
Add 'project' to user table, add 'manager' role, conditional web render
- Addresses issues #40 #45 #82 - Reworked Navigation Header for all roles - 'Manager' role added, can be assigned a project-id in config by admins - BREAKING! -> Added 'project' column in SQLite3 table 'user' - Manager-Assigned project will be added to all graphql filters: Only show Jobs and Users of given project - 'My Jobs' Tab for all Roles - Switched from Bool "isAdmin" to integer authLevels - Removed critical data frontend logging - Reworked repo.query.SecurityCheck()
This commit is contained in:
@@ -822,17 +822,26 @@ func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
username, password, role, name, email := r.FormValue("username"), r.FormValue("password"), r.FormValue("role"), r.FormValue("name"), r.FormValue("email")
|
||||
username, password, role, name, email, project := r.FormValue("username"), r.FormValue("password"), r.FormValue("role"), r.FormValue("name"), r.FormValue("email"), r.FormValue("project")
|
||||
if len(password) == 0 && role != auth.RoleApi {
|
||||
http.Error(rw, "only API users are allowed to have a blank password (login will be impossible)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(project) != 0 && role != auth.RoleManager {
|
||||
http.Error(rw, "only managers require a project (can be changed later)", http.StatusBadRequest)
|
||||
return
|
||||
} else if (len(project) == 0 && role == auth.RoleManager) {
|
||||
http.Error(rw, "managers require a project to manage (can be changed later)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := api.Authentication.AddUser(&auth.User{
|
||||
Username: username,
|
||||
Name: name,
|
||||
Password: password,
|
||||
Email: email,
|
||||
Project: project,
|
||||
Roles: []string{role}}); err != nil {
|
||||
http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
@@ -880,6 +889,8 @@ func (api *RestApi) updateUser(rw http.ResponseWriter, r *http.Request) {
|
||||
// Get Values
|
||||
newrole := r.FormValue("add-role")
|
||||
delrole := r.FormValue("remove-role")
|
||||
newproj := r.FormValue("add-project")
|
||||
delproj := r.FormValue("remove-project")
|
||||
|
||||
// TODO: Handle anything but roles...
|
||||
if newrole != "" {
|
||||
@@ -894,8 +905,20 @@ func (api *RestApi) updateUser(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
rw.Write([]byte("Remove Role Success"))
|
||||
} else if newproj != "" {
|
||||
if err := api.Authentication.AddProject(r.Context(), mux.Vars(r)["id"], newproj); err != nil {
|
||||
http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
rw.Write([]byte("Set Project Success"))
|
||||
} else if delproj != "" {
|
||||
if err := api.Authentication.RemoveProject(r.Context(), mux.Vars(r)["id"], delproj); err != nil {
|
||||
http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
rw.Write([]byte("Reset Project Success"))
|
||||
} else {
|
||||
http.Error(rw, "Not Add or Del?", http.StatusInternalServerError)
|
||||
http.Error(rw, "Not Add or Del [role|project]?", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -21,12 +21,12 @@ import (
|
||||
const (
|
||||
RoleAdmin string = "admin"
|
||||
RoleSupport string = "support"
|
||||
RoleApi string = "api"
|
||||
RoleManager string = "manager"
|
||||
RoleUser string = "user"
|
||||
RoleProject string = "project"
|
||||
RoleApi string = "api"
|
||||
)
|
||||
|
||||
var validRoles = [5]string{RoleAdmin, RoleSupport, RoleApi, RoleUser, RoleProject}
|
||||
var validRoles = [5]string{RoleUser, RoleManager, RoleSupport, RoleAdmin, RoleApi}
|
||||
|
||||
const (
|
||||
AuthViaLocalPassword int8 = 0
|
||||
@@ -105,6 +105,31 @@ func (u *User) HasNotRoles(queryroles []string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// Find highest role, returns integer
|
||||
func (u *User) GetAuthLevel() int {
|
||||
if (u.HasRole(RoleAdmin)) {
|
||||
return 5
|
||||
} else if (u.HasRole(RoleSupport)) {
|
||||
return 4
|
||||
} else if (u.HasRole(RoleManager)) {
|
||||
return 3
|
||||
} else if (u.HasRole(RoleUser)) {
|
||||
return 2
|
||||
} else if (u.HasRole(RoleApi)) {
|
||||
return 1
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (u *User) HasProject(project string) bool {
|
||||
if (u.Project != "" && u.Project == project) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func IsValidRole(role string) bool {
|
||||
for _, r := range validRoles {
|
||||
if r == role {
|
||||
@@ -156,7 +181,8 @@ func Init(db *sqlx.DB,
|
||||
ldap tinyint NOT NULL DEFAULT 0, /* col called "ldap" for historic reasons, fills the "AuthSource" */
|
||||
name varchar(255) DEFAULT NULL,
|
||||
roles varchar(255) NOT NULL DEFAULT "[]",
|
||||
email varchar(255) DEFAULT NULL);`)
|
||||
email varchar(255) DEFAULT NULL,
|
||||
project varchar(255) DEFAULT NULL);`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -214,9 +240,11 @@ func (auth *Authentication) AuthViaSession(
|
||||
}
|
||||
|
||||
username, _ := session.Values["username"].(string)
|
||||
project, _ := session.Values["project"].(string)
|
||||
roles, _ := session.Values["roles"].([]string)
|
||||
return &User{
|
||||
Username: username,
|
||||
Project: project,
|
||||
Roles: roles,
|
||||
AuthSource: -1,
|
||||
}, nil
|
||||
@@ -261,6 +289,7 @@ func (auth *Authentication) Login(
|
||||
session.Options.MaxAge = int(auth.SessionMaxAge.Seconds())
|
||||
}
|
||||
session.Values["username"] = user.Username
|
||||
session.Values["project"] = user.Project
|
||||
session.Values["roles"] = user.Roles
|
||||
if err := auth.sessionStore.Save(r, rw, session); err != nil {
|
||||
log.Errorf("session save failed: %s", err.Error())
|
||||
@@ -268,7 +297,7 @@ func (auth *Authentication) Login(
|
||||
return
|
||||
}
|
||||
|
||||
log.Infof("login successfull: user: %#v (roles: %v)", user.Username, user.Roles)
|
||||
log.Infof("login successfull: user: %#v (roles: %v, project: %v)", user.Username, user.Roles, user.Project)
|
||||
ctx := context.WithValue(r.Context(), ContextUserKey, user)
|
||||
onsuccess.ServeHTTP(rw, r.WithContext(ctx))
|
||||
return
|
||||
|
@@ -21,16 +21,17 @@ import (
|
||||
func (auth *Authentication) GetUser(username string) (*User, error) {
|
||||
|
||||
user := &User{Username: username}
|
||||
var hashedPassword, name, rawRoles, email sql.NullString
|
||||
if err := sq.Select("password", "ldap", "name", "roles", "email").From("user").
|
||||
var hashedPassword, name, rawRoles, email, project sql.NullString
|
||||
if err := sq.Select("password", "ldap", "name", "roles", "email", "project").From("user").
|
||||
Where("user.username = ?", username).RunWith(auth.db).
|
||||
QueryRow().Scan(&hashedPassword, &user.AuthSource, &name, &rawRoles, &email); err != nil {
|
||||
QueryRow().Scan(&hashedPassword, &user.AuthSource, &name, &rawRoles, &email, &project); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user.Password = hashedPassword.String
|
||||
user.Name = name.String
|
||||
user.Email = email.String
|
||||
user.Project = project.String
|
||||
if rawRoles.Valid {
|
||||
if err := json.Unmarshal([]byte(rawRoles.String), &user.Roles); err != nil {
|
||||
return nil, err
|
||||
@@ -54,6 +55,10 @@ func (auth *Authentication) AddUser(user *User) error {
|
||||
cols = append(cols, "email")
|
||||
vals = append(vals, user.Email)
|
||||
}
|
||||
if user.Project != "" {
|
||||
cols = append(cols, "project")
|
||||
vals = append(vals, user.Project)
|
||||
}
|
||||
if user.Password != "" {
|
||||
password, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
@@ -67,7 +72,7 @@ func (auth *Authentication) AddUser(user *User) error {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Infof("new user %#v created (roles: %s, auth-source: %d)", user.Username, rolesJson, user.AuthSource)
|
||||
log.Infof("new user %#v created (roles: %s, auth-source: %d, project: %s)", user.Username, rolesJson, user.AuthSource, user.Project)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -79,7 +84,7 @@ func (auth *Authentication) DelUser(username string) error {
|
||||
|
||||
func (auth *Authentication) ListUsers(specialsOnly bool) ([]*User, error) {
|
||||
|
||||
q := sq.Select("username", "name", "email", "roles").From("user")
|
||||
q := sq.Select("username", "name", "email", "roles", "project").From("user")
|
||||
if specialsOnly {
|
||||
q = q.Where("(roles != '[\"user\"]' AND roles != '[]')")
|
||||
}
|
||||
@@ -94,8 +99,8 @@ func (auth *Authentication) ListUsers(specialsOnly bool) ([]*User, error) {
|
||||
for rows.Next() {
|
||||
rawroles := ""
|
||||
user := &User{}
|
||||
var name, email sql.NullString
|
||||
if err := rows.Scan(&user.Username, &name, &email, &rawroles); err != nil {
|
||||
var name, email, project sql.NullString
|
||||
if err := rows.Scan(&user.Username, &name, &email, &rawroles, &project); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -105,6 +110,7 @@ func (auth *Authentication) ListUsers(specialsOnly bool) ([]*User, error) {
|
||||
|
||||
user.Name = name.String
|
||||
user.Email = email.String
|
||||
user.Project = project.String
|
||||
users = append(users, user)
|
||||
}
|
||||
return users, nil
|
||||
@@ -145,6 +151,10 @@ func (auth *Authentication) RemoveRole(ctx context.Context, username string, rol
|
||||
return fmt.Errorf("invalid user role: %#v", role)
|
||||
}
|
||||
|
||||
if (role == RoleManager && len(user.Project) != 0) {
|
||||
return fmt.Errorf("Cannot remove role 'manager' while user %#v still has an assigned project!", username)
|
||||
}
|
||||
|
||||
var exists bool
|
||||
var newroles []string
|
||||
for _, r := range user.Roles {
|
||||
@@ -166,9 +176,53 @@ func (auth *Authentication) RemoveRole(ctx context.Context, username string, rol
|
||||
}
|
||||
}
|
||||
|
||||
func (auth *Authentication) AddProject(
|
||||
ctx context.Context,
|
||||
username string,
|
||||
project string) error {
|
||||
|
||||
user, err := auth.GetUser(username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !user.HasRole(RoleManager) {
|
||||
return fmt.Errorf("user '%#v' is not a manager!", username)
|
||||
}
|
||||
|
||||
if user.HasProject(project) {
|
||||
return fmt.Errorf("user '%#v' already manages project '%#v'", username, project)
|
||||
}
|
||||
|
||||
if _, err := sq.Update("user").Set("project", project).Where("user.username = ?", username).RunWith(auth.db).Exec(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (auth *Authentication) RemoveProject(ctx context.Context, username string, project string) error {
|
||||
user, err := auth.GetUser(username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !user.HasRole(RoleManager) {
|
||||
return fmt.Errorf("user '%#v' is not a manager!", username)
|
||||
}
|
||||
|
||||
if !user.HasProject(project) {
|
||||
return fmt.Errorf("user '%#v': Cannot remove project '%#v' - Does not match!", username, project)
|
||||
}
|
||||
|
||||
if _, err := sq.Update("user").Set("project", "").Where("user.username = ?", username).Where("user.project = ?", project).RunWith(auth.db).Exec(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func FetchUser(ctx context.Context, db *sqlx.DB, username string) (*model.User, error) {
|
||||
me := GetUser(ctx)
|
||||
if me != nil && me.Username != username && me.HasNotRoles([]string{RoleAdmin, RoleSupport}) {
|
||||
if me != nil && me.Username != username && me.HasNotRoles([]string{RoleAdmin, RoleSupport, RoleManager}) {
|
||||
return nil, errors.New("forbidden")
|
||||
}
|
||||
|
||||
@@ -185,5 +239,6 @@ func FetchUser(ctx context.Context, db *sqlx.DB, username string) (*model.User,
|
||||
|
||||
user.Name = name.String
|
||||
user.Email = email.String
|
||||
// user.Project = project.String
|
||||
return user, nil
|
||||
}
|
||||
|
@@ -152,7 +152,7 @@ func (r *queryResolver) Job(ctx context.Context, id string) (*schema.Job, error)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if user := auth.GetUser(ctx); user != nil && job.User != user.Username && user.HasNotRoles([]string{auth.RoleAdmin, auth.RoleSupport}){
|
||||
if user := auth.GetUser(ctx); user != nil && job.User != user.Username && user.HasNotRoles([]string{auth.RoleAdmin, auth.RoleSupport, auth.RoleManager}){
|
||||
return nil, errors.New("you are not allowed to see this job")
|
||||
}
|
||||
|
||||
|
@@ -39,9 +39,9 @@ func (r *queryResolver) jobsStatistics(ctx context.Context, filter []*model.JobF
|
||||
for _, cluster := range archive.Clusters {
|
||||
for _, subcluster := range cluster.SubClusters {
|
||||
corehoursCol := fmt.Sprintf("CAST(ROUND(SUM(job.duration * job.num_nodes * %d * %d) / 3600) as int)", subcluster.SocketsPerNode, subcluster.CoresPerSocket)
|
||||
var query sq.SelectBuilder
|
||||
var rawQuery sq.SelectBuilder
|
||||
if groupBy == nil {
|
||||
query = sq.Select(
|
||||
rawQuery = sq.Select(
|
||||
"''",
|
||||
"COUNT(job.id)",
|
||||
"CAST(ROUND(SUM(job.duration) / 3600) as int)",
|
||||
@@ -49,7 +49,7 @@ func (r *queryResolver) jobsStatistics(ctx context.Context, filter []*model.JobF
|
||||
).From("job")
|
||||
} else {
|
||||
col := groupBy2column[*groupBy]
|
||||
query = sq.Select(
|
||||
rawQuery = sq.Select(
|
||||
col,
|
||||
"COUNT(job.id)",
|
||||
"CAST(ROUND(SUM(job.duration) / 3600) as int)",
|
||||
@@ -57,11 +57,16 @@ func (r *queryResolver) jobsStatistics(ctx context.Context, filter []*model.JobF
|
||||
).From("job").GroupBy(col)
|
||||
}
|
||||
|
||||
query = query.
|
||||
rawQuery = rawQuery.
|
||||
Where("job.cluster = ?", cluster.Name).
|
||||
Where("job.subcluster = ?", subcluster.Name)
|
||||
|
||||
query = repository.SecurityCheck(ctx, query)
|
||||
query, qerr := repository.SecurityCheck(ctx, rawQuery)
|
||||
|
||||
if qerr != nil {
|
||||
return nil, qerr
|
||||
}
|
||||
|
||||
for _, f := range filter {
|
||||
query = repository.BuildWhereClause(f, query)
|
||||
}
|
||||
@@ -97,8 +102,13 @@ func (r *queryResolver) jobsStatistics(ctx context.Context, filter []*model.JobF
|
||||
}
|
||||
|
||||
if groupBy == nil {
|
||||
query := sq.Select("COUNT(job.id)").From("job").Where("job.duration < ?", ShortJobDuration)
|
||||
query = repository.SecurityCheck(ctx, query)
|
||||
|
||||
query, qerr := repository.SecurityCheck(ctx, sq.Select("COUNT(job.id)").From("job").Where("job.duration < ?", ShortJobDuration))
|
||||
|
||||
if qerr != nil {
|
||||
return nil, qerr
|
||||
}
|
||||
|
||||
for _, f := range filter {
|
||||
query = repository.BuildWhereClause(f, query)
|
||||
}
|
||||
@@ -107,8 +117,13 @@ func (r *queryResolver) jobsStatistics(ctx context.Context, filter []*model.JobF
|
||||
}
|
||||
} else {
|
||||
col := groupBy2column[*groupBy]
|
||||
query := sq.Select(col, "COUNT(job.id)").From("job").Where("job.duration < ?", ShortJobDuration)
|
||||
query = repository.SecurityCheck(ctx, query)
|
||||
|
||||
query, qerr := repository.SecurityCheck(ctx, sq.Select(col, "COUNT(job.id)").From("job").Where("job.duration < ?", ShortJobDuration))
|
||||
|
||||
if qerr != nil {
|
||||
return nil, qerr
|
||||
}
|
||||
|
||||
for _, f := range filter {
|
||||
query = repository.BuildWhereClause(f, query)
|
||||
}
|
||||
@@ -170,8 +185,13 @@ func (r *queryResolver) jobsStatistics(ctx context.Context, filter []*model.JobF
|
||||
// `value` must be the column grouped by, but renamed to "value". `id` and `col` can optionally be used
|
||||
// to add a condition to the query of the kind "<col> = <id>".
|
||||
func (r *queryResolver) jobsStatisticsHistogram(ctx context.Context, value string, filters []*model.JobFilter, id, col string) ([]*model.HistoPoint, error) {
|
||||
query := sq.Select(value, "COUNT(job.id) AS count").From("job")
|
||||
query = repository.SecurityCheck(ctx, query)
|
||||
|
||||
query, qerr := repository.SecurityCheck(ctx, sq.Select(value, "COUNT(job.id) AS count").From("job"))
|
||||
|
||||
if qerr != nil {
|
||||
return nil, qerr
|
||||
}
|
||||
|
||||
for _, f := range filters {
|
||||
query = repository.BuildWhereClause(f, query)
|
||||
}
|
||||
|
@@ -295,8 +295,12 @@ func (r *JobRepository) CountGroupedJobs(ctx context.Context, aggreg model.Aggre
|
||||
}
|
||||
}
|
||||
|
||||
q := sq.Select("job."+string(aggreg), count).From("job").GroupBy("job." + string(aggreg)).OrderBy("count DESC")
|
||||
q = SecurityCheck(ctx, q)
|
||||
q, qerr := SecurityCheck(ctx, sq.Select("job."+string(aggreg), count).From("job").GroupBy("job." + string(aggreg)).OrderBy("count DESC"))
|
||||
|
||||
if qerr != nil {
|
||||
return nil, qerr
|
||||
}
|
||||
|
||||
for _, f := range filters {
|
||||
q = BuildWhereClause(f, q)
|
||||
}
|
||||
|
@@ -26,8 +26,11 @@ func (r *JobRepository) QueryJobs(
|
||||
page *model.PageRequest,
|
||||
order *model.OrderByInput) ([]*schema.Job, error) {
|
||||
|
||||
query := sq.Select(jobColumns...).From("job")
|
||||
query = SecurityCheck(ctx, query)
|
||||
query, qerr := SecurityCheck(ctx, sq.Select(jobColumns...).From("job"))
|
||||
|
||||
if qerr != nil {
|
||||
return nil, qerr
|
||||
}
|
||||
|
||||
if order != nil {
|
||||
field := toSnakeCase(order.Field)
|
||||
@@ -79,8 +82,12 @@ func (r *JobRepository) CountJobs(
|
||||
filters []*model.JobFilter) (int, error) {
|
||||
|
||||
// count all jobs:
|
||||
query := sq.Select("count(*)").From("job")
|
||||
query = SecurityCheck(ctx, query)
|
||||
query, qerr := SecurityCheck(ctx, sq.Select("count(*)").From("job"))
|
||||
|
||||
if qerr != nil {
|
||||
return 0, qerr
|
||||
}
|
||||
|
||||
for _, f := range filters {
|
||||
query = BuildWhereClause(f, query)
|
||||
}
|
||||
@@ -92,13 +99,18 @@ func (r *JobRepository) CountJobs(
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func SecurityCheck(ctx context.Context, query sq.SelectBuilder) sq.SelectBuilder {
|
||||
func SecurityCheck(ctx context.Context, query sq.SelectBuilder) (queryOut sq.SelectBuilder, err error) {
|
||||
user := auth.GetUser(ctx)
|
||||
if user == nil || user.HasAnyRole([]string{auth.RoleAdmin, auth.RoleApi, auth.RoleSupport}) {
|
||||
return query
|
||||
if user == nil || user.HasAnyRole([]string{auth.RoleAdmin, auth.RoleSupport, auth.RoleApi}) {
|
||||
return query, nil
|
||||
} else if (user.HasRole(auth.RoleManager)) { // Manager (Might be doublefiltered by frontend: should not matter)
|
||||
return query.Where("job.project = ?", user.Project), nil
|
||||
} else if (user.HasRole(auth.RoleUser)) { // User
|
||||
return query.Where("job.user = ?", user.Username), nil
|
||||
} else { // Unauthorized
|
||||
var qnil sq.SelectBuilder
|
||||
return qnil, errors.New(fmt.Sprintf("User '%s' with unknown roles! [%#v]\n", user.Username, user.Roles))
|
||||
}
|
||||
|
||||
return query.Where("job.user = ?", user.Username)
|
||||
}
|
||||
|
||||
// Build a sq.SelectBuilder out of a schema.JobFilter.
|
||||
|
@@ -103,9 +103,11 @@ func setupUserRoute(i InfoType, r *http.Request) InfoType {
|
||||
username := mux.Vars(r)["id"]
|
||||
i["id"] = username
|
||||
i["username"] = username
|
||||
// TODO: If forbidden (== err exists), redirect to error page
|
||||
if user, _ := auth.FetchUser(r.Context(), jobRepo.DB, username); user != nil {
|
||||
i["name"] = user.Name
|
||||
i["email"] = user.Email
|
||||
// i["project"] = user.Project
|
||||
}
|
||||
return i
|
||||
}
|
||||
@@ -270,17 +272,17 @@ func SetupRoutes(router *mux.Router, version string, hash string, buildTime stri
|
||||
title = strings.Replace(route.Title, "<ID>", id.(string), 1)
|
||||
}
|
||||
|
||||
username, isAdmin, isSupporter := "", true, true
|
||||
username, project, authLevel := "", "", 0
|
||||
|
||||
if user := auth.GetUser(r.Context()); user != nil {
|
||||
username = user.Username
|
||||
isAdmin = user.HasRole(auth.RoleAdmin)
|
||||
isSupporter = user.HasRole(auth.RoleSupport)
|
||||
username = user.Username
|
||||
project = user.Project
|
||||
authLevel = user.GetAuthLevel()
|
||||
}
|
||||
|
||||
page := web.Page{
|
||||
Title: title,
|
||||
User: web.User{Username: username, IsAdmin: isAdmin, IsSupporter: isSupporter},
|
||||
User: web.User{Username: username, Project: project, AuthLevel: authLevel},
|
||||
Build: web.Build{Version: version, Hash: hash, Buildtime: buildTime},
|
||||
Config: conf,
|
||||
Infos: infos,
|
||||
|
Reference in New Issue
Block a user