chore: import upstream snapshot with attribution
CI / Check and lint (push) Failing after 1s
Lint / Lint (ubuntu-latest) (push) Failing after 1s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:22:32 +08:00
commit 39dbe3a57d
1131 changed files with 272709 additions and 0 deletions
+621
View File
@@ -0,0 +1,621 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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.
*/
package cli
import (
"bytes"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
"text/template"
"github.com/Masterminds/semver/v3"
"github.com/apache/answer/pkg/dir"
"github.com/apache/answer/pkg/writer"
"github.com/segmentfault/pacman/log"
"gopkg.in/yaml.v3"
)
const (
mainGoTpl = `package main
import (
answercmd "github.com/apache/answer/cmd"
// remote plugins
{{- range .remote_plugins}}
_ "{{.}}"
{{- end}}
// local plugins
{{- range .local_plugins}}
_ "answer/{{.}}"
{{- end}}
)
func main() {
answercmd.Main()
}
`
goModTpl = `module answer
go 1.23
`
)
type answerBuilder struct {
buildingMaterial *buildingMaterial
BuildError error
}
type buildingMaterial struct {
answerModuleReplacement string
plugins []*pluginInfo
outputPath string
tmpDir string
originalAnswerInfo OriginalAnswerInfo
}
type OriginalAnswerInfo struct {
Version string
Revision string
Time string
}
type pluginInfo struct {
// Name of the plugin e.g. github.com/apache/answer-plugins/github-connector
Name string
// Path to the plugin. If path exist, read plugin from local filesystem
Path string
// Version of the plugin
Version string
}
func newAnswerBuilder(buildDir, outputPath string, plugins []string, originalAnswerInfo OriginalAnswerInfo) *answerBuilder {
material := &buildingMaterial{originalAnswerInfo: originalAnswerInfo}
parentDir, _ := filepath.Abs(".")
if buildDir != "" {
material.tmpDir = filepath.Join(parentDir, buildDir)
} else {
material.tmpDir, _ = os.MkdirTemp(parentDir, "answer_build")
}
if len(outputPath) == 0 {
outputPath = filepath.Join(parentDir, "new_answer")
}
material.outputPath, _ = filepath.Abs(outputPath)
material.plugins = formatPlugins(plugins)
material.answerModuleReplacement = os.Getenv("ANSWER_MODULE")
return &answerBuilder{
buildingMaterial: material,
}
}
func (a *answerBuilder) DoTask(task func(b *buildingMaterial) error) {
if a.BuildError != nil {
return
}
a.BuildError = task(a.buildingMaterial)
}
// BuildNewAnswer builds a new answer with specified plugins
func BuildNewAnswer(buildDir, outputPath string, plugins []string, originalAnswerInfo OriginalAnswerInfo) (err error) {
builder := newAnswerBuilder(buildDir, outputPath, plugins, originalAnswerInfo)
builder.DoTask(createMainGoFile)
builder.DoTask(downloadGoModFile)
builder.DoTask(movePluginToVendor)
builder.DoTask(copyUIFiles)
builder.DoTask(buildUI)
builder.DoTask(mergeI18nFiles)
builder.DoTask(buildBinary)
builder.DoTask(cleanByproduct)
return builder.BuildError
}
func formatPlugins(plugins []string) (formatted []*pluginInfo) {
for _, plugin := range plugins {
plugin = strings.TrimSpace(plugin)
// plugin description like this 'github.com/apache/answer-plugins/github-connector@latest=/local/path'
info := &pluginInfo{}
plugin, info.Path, _ = strings.Cut(plugin, "=")
info.Name, info.Version, _ = strings.Cut(plugin, "@")
// Resolve local path to absolute since build runs in a temp directory
if len(info.Path) > 0 {
if absPath, err := filepath.Abs(info.Path); err == nil {
info.Path = absPath
}
}
formatted = append(formatted, info)
}
return formatted
}
// createMainGoFile creates main.go file in tmp dir that content is mainGoTpl
func createMainGoFile(b *buildingMaterial) (err error) {
fmt.Printf("[build] build dir: %s\n", b.tmpDir)
err = dir.CreateDirIfNotExist(b.tmpDir)
if err != nil {
return err
}
var (
remotePlugins []string
)
for _, p := range b.plugins {
remotePlugins = append(remotePlugins, versionedModulePath(p.Name, p.Version))
}
mainGoFile := &bytes.Buffer{}
tmpl, err := template.New("main").Parse(mainGoTpl)
if err != nil {
return err
}
err = tmpl.Execute(mainGoFile, map[string]any{
"remote_plugins": remotePlugins,
})
if err != nil {
return err
}
err = writer.WriteFile(filepath.Join(b.tmpDir, "main.go"), mainGoFile.String())
if err != nil {
return err
}
err = writer.WriteFile(filepath.Join(b.tmpDir, "go.mod"), goModTpl)
if err != nil {
return err
}
for _, p := range b.plugins {
// If user set a path, use it to replace the module with local path
if len(p.Path) > 0 {
var replacement string
if len(p.Version) > 0 {
replacement = fmt.Sprintf("%s@%s=%s", p.Name, p.Version, p.Path)
} else {
replacement = fmt.Sprintf("%s=%s", p.Name, p.Path)
}
err = b.newExecCmd("go", "mod", "edit", "-replace", replacement).Run()
} else if len(p.Version) > 0 {
// If user specify a version, use it to get specific version of the module
err = b.newExecCmd("go", "get", fmt.Sprintf("%s@%s", p.Name, p.Version)).Run()
}
if err != nil {
return err
}
}
return
}
// downloadGoModFile run go mod commands to download dependencies
func downloadGoModFile(b *buildingMaterial) (err error) {
answerReplacement := b.answerModuleReplacement
// If no replacement specified and current binary is v2+, auto-determine replacement.
// This is needed because go mod tidy would otherwise resolve github.com/apache/answer
// to the latest v1.x version, causing v2+ features (e.g. AI/MCP) to disappear.
if len(answerReplacement) == 0 && b.originalAnswerInfo.Version != "" {
ver, verErr := semver.NewVersion(strings.TrimPrefix(b.originalAnswerInfo.Version, "v"))
if verErr == nil && ver.Major() >= 2 {
answerReplacement = fmt.Sprintf("github.com/apache/answer@%s", b.originalAnswerInfo.Version)
}
}
if len(answerReplacement) > 0 {
// For v2+ versioned module paths (e.g. github.com/apache/answer@v2.0.0),
// go mod tidy rejects the version because the module path lacks a /v2 suffix.
// Work around this by cloning the repo locally and using a local path replacement.
localPath, resolveErr := resolveAnswerModuleReplacement(answerReplacement, b.tmpDir)
if resolveErr != nil {
return resolveErr
}
replacement := fmt.Sprintf("%s=%s", "github.com/apache/answer", localPath)
err = b.newExecCmd("go", "mod", "edit", "-replace", replacement).Run()
if err != nil {
return err
}
}
err = b.newExecCmd("go", "mod", "tidy").Run()
if err != nil {
return err
}
err = b.newExecCmd("go", "mod", "vendor").Run()
if err != nil {
return err
}
return
}
// resolveAnswerModuleReplacement resolves the ANSWER_MODULE value to a usable local path or
// remote replacement string. For v2+ versioned module paths (e.g. github.com/apache/answer@v2.0.0),
// Go module system rejects the version because the module path has no /v2 suffix. In that case
// the repository is cloned locally and the local path is returned instead.
func resolveAnswerModuleReplacement(replacement, tmpDir string) (string, error) {
// Local paths can be used as-is.
if strings.HasPrefix(replacement, "/") || strings.HasPrefix(replacement, "./") || strings.HasPrefix(replacement, "../") {
return replacement, nil
}
// Parse module@version format.
moduleName, version, hasVersion := strings.Cut(replacement, "@")
if !hasVersion {
return replacement, nil
}
// Only handle v2+ versions on module paths without the /vN suffix.
ver, err := semver.StrictNewVersion(strings.TrimPrefix(version, "v"))
if err != nil || ver.Major() < 2 {
return replacement, nil
}
if strings.HasSuffix(moduleName, fmt.Sprintf("/v%d", ver.Major())) {
return replacement, nil
}
// Clone the repo to a local directory and return its path.
gitURL := "https://" + moduleName
tag := "v" + strings.TrimPrefix(version, "v")
localPath := filepath.Join(filepath.Dir(tmpDir), fmt.Sprintf("answer_src_%s", strings.ReplaceAll(version, ".", "_")))
if _, statErr := os.Stat(localPath); statErr == nil {
fmt.Printf("[build] using cached local clone at %s\n", localPath)
return localPath, nil
}
fmt.Printf("[build] v2+ module detected, cloning %s@%s to local path %s...\n", moduleName, version, localPath)
cloneCmd := exec.Command("git", "clone", "--depth=1", "--branch="+tag, gitURL, localPath)
cloneCmd.Stdout = os.Stdout
cloneCmd.Stderr = os.Stderr
if err = cloneCmd.Run(); err != nil {
return "", fmt.Errorf(
"failed to clone %s@%s: %w\nTip: set ANSWER_MODULE to a local checkout path instead, e.g. ANSWER_MODULE=/path/to/answer",
moduleName, version, err,
)
}
fmt.Printf("[build] successfully cloned to %s\n", localPath)
return localPath, nil
}
// movePluginToVendor move plugin to vendor dir
// Traverse the plugins, and if the plugin path is not github.com/apache/answer-plugins, move the contents of the current plugin to the vendor/github.com/apache/answer-plugins/ directory.
func movePluginToVendor(b *buildingMaterial) (err error) {
pluginsDir := filepath.Join(b.tmpDir, "vendor/github.com/apache/answer-plugins/")
for _, p := range b.plugins {
pluginDir := filepath.Join(b.tmpDir, "vendor/", p.Name)
pluginName := filepath.Base(p.Name)
if !strings.HasPrefix(p.Name, "github.com/apache/answer-plugins/") {
fmt.Printf("try to copy dir from %s to %s\n", pluginDir, filepath.Join(pluginsDir, pluginName))
err = copyDirEntries(os.DirFS(pluginDir), ".", filepath.Join(pluginsDir, pluginName), "node_modules")
if err != nil {
return err
}
}
}
return nil
}
// copyUIFiles copy ui files from answer module to tmp dir
func copyUIFiles(b *buildingMaterial) (err error) {
goListCmd := b.newExecCmd("go", "list", "-mod=mod", "-m", "-f", "{{.Dir}}", "github.com/apache/answer")
buf := new(bytes.Buffer)
goListCmd.Stdout = buf
if err = goListCmd.Run(); err != nil {
return fmt.Errorf("failed to run go list: %w", err)
}
answerDir := strings.TrimSpace(buf.String())
goModUIDir := filepath.Join(answerDir, "ui")
localUIBuildDir := filepath.Join(b.tmpDir, "vendor/github.com/apache/answer/ui/")
// The node_modules folder generated during development will interfere packaging, so it needs to be ignored.
if err = copyDirEntries(os.DirFS(goModUIDir), ".", localUIBuildDir, "node_modules"); err != nil {
return fmt.Errorf("failed to copy ui files: %w", err)
}
pluginsDir := filepath.Join(b.tmpDir, "vendor/github.com/apache/answer-plugins/")
localUIPluginDir := filepath.Join(localUIBuildDir, "src/plugins/")
// copy plugins dir
fmt.Printf("try to copy dir from %s to %s\n", pluginsDir, localUIPluginDir)
// if plugins dir not exist means no plugins
if !dir.CheckDirExist(pluginsDir) {
return nil
}
pluginsDirEntries, err := os.ReadDir(pluginsDir)
if err != nil {
return fmt.Errorf("failed to read plugins dir: %w", err)
}
for _, entry := range pluginsDirEntries {
if !entry.IsDir() {
continue
}
sourcePluginDir := filepath.Join(pluginsDir, entry.Name())
// check if plugin is a ui plugin
packageJsonPath := filepath.Join(sourcePluginDir, "package.json")
fmt.Printf("check if %s is a ui plugin\n", packageJsonPath)
if !dir.CheckFileExist(packageJsonPath) {
continue
}
pnpmInstallCmd := b.newExecCmd("pnpm", "install")
pnpmInstallCmd.Dir = sourcePluginDir
if err = pnpmInstallCmd.Run(); err != nil {
return fmt.Errorf("failed to install plugin dependencies: %w", err)
}
localPluginDir := filepath.Join(localUIPluginDir, entry.Name())
fmt.Printf("try to copy dir from %s to %s\n", sourcePluginDir, localPluginDir)
if err = copyDirEntries(os.DirFS(sourcePluginDir), ".", localPluginDir, "node_modules"); err != nil {
return fmt.Errorf("failed to copy ui files: %w", err)
}
}
formatUIPluginsDirName(localUIPluginDir)
return nil
}
// buildUI run pnpm install and pnpm build commands to build ui
func buildUI(b *buildingMaterial) (err error) {
localUIBuildDir := filepath.Join(b.tmpDir, "vendor/github.com/apache/answer/ui")
pnpmInstallCmd := b.newExecCmd("pnpm", "pre-install")
pnpmInstallCmd.Dir = localUIBuildDir
if err = pnpmInstallCmd.Run(); err != nil {
return err
}
pnpmBuildCmd := b.newExecCmd("pnpm", "build")
pnpmBuildCmd.Dir = localUIBuildDir
if err = pnpmBuildCmd.Run(); err != nil {
return err
}
return nil
}
// mergeI18nFiles merge i18n files
func mergeI18nFiles(b *buildingMaterial) (err error) {
fmt.Printf("try to merge i18n files\n")
type YamlPluginContent struct {
Plugin map[string]any `yaml:"plugin"`
}
pluginAllTranslations := make(map[string]*YamlPluginContent)
for _, plugin := range b.plugins {
i18nDir := filepath.Join(b.tmpDir, fmt.Sprintf("vendor/%s/i18n", plugin.Name))
fmt.Println("i18n dir: ", i18nDir)
if !dir.CheckDirExist(i18nDir) {
continue
}
entries, err := os.ReadDir(i18nDir)
if err != nil {
return err
}
for _, file := range entries {
// ignore directory
if file.IsDir() {
continue
}
// ignore non-YAML file
if filepath.Ext(file.Name()) != ".yaml" {
continue
}
buf, err := os.ReadFile(filepath.Join(i18nDir, file.Name()))
if err != nil {
log.Debugf("read translation file failed: %s %s", file.Name(), err)
continue
}
translation := &YamlPluginContent{}
if err = yaml.Unmarshal(buf, translation); err != nil {
log.Debugf("unmarshal translation file failed: %s %s", file.Name(), err)
continue
}
if pluginAllTranslations[file.Name()] == nil {
pluginAllTranslations[file.Name()] = &YamlPluginContent{Plugin: make(map[string]any)}
}
for k, v := range translation.Plugin {
pluginAllTranslations[file.Name()].Plugin[k] = v
}
}
}
originalI18nDir := filepath.Join(b.tmpDir, "vendor/github.com/apache/answer/i18n")
entries, err := os.ReadDir(originalI18nDir)
if err != nil {
return err
}
for _, file := range entries {
// ignore directory
if file.IsDir() {
continue
}
// ignore non-YAML file
filename := file.Name()
if filepath.Ext(filename) != ".yaml" && filename != "i18n.yaml" {
continue
}
// if plugin don't have this translation file, ignore it
if pluginAllTranslations[filename] == nil {
continue
}
out, _ := yaml.Marshal(pluginAllTranslations[filename])
buf, err := os.OpenFile(filepath.Join(originalI18nDir, filename), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Debugf("read translation file failed: %s %s", filename, err)
continue
}
_, _ = buf.WriteString("\n")
_, _ = buf.Write(out)
_ = buf.Close()
}
return err
}
func copyDirEntries(sourceFs fs.FS, sourceDir, targetDir string, ignoreDir ...string) (err error) {
err = dir.CreateDirIfNotExist(targetDir)
if err != nil {
return err
}
ignoreThisDir := func(path string) bool {
for _, s := range ignoreDir {
if strings.HasPrefix(path, s) {
return true
}
// Also ignore nested occurrences, e.g. src/plugins/foo/node_modules
if strings.Contains(path, string(filepath.Separator)+s) {
return true
}
if strings.Contains(path, "/"+s) {
return true
}
}
return false
}
err = fs.WalkDir(sourceFs, sourceDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if ignoreThisDir(path) {
return nil
}
// Convert the path to use forward slashes, important because we use embedded FS which always uses forward slashes
path = filepath.ToSlash(path)
// Construct the absolute path for the source file/directory
srcPath := filepath.Join(sourceDir, path)
srcPath = filepath.ToSlash(srcPath)
// Construct the absolute path for the destination file/directory
dstPath := filepath.Join(targetDir, path)
if d.IsDir() {
// Create the directory in the destination
err := os.MkdirAll(dstPath, os.ModePerm)
if err != nil {
return fmt.Errorf("failed to create directory %s: %w", dstPath, err)
}
} else {
// Open the source file
srcFile, err := sourceFs.Open(srcPath)
if err != nil {
return fmt.Errorf("failed to open source file %s: %w", srcPath, err)
}
defer srcFile.Close()
// Create the destination file
dstFile, err := os.Create(dstPath)
if err != nil {
return fmt.Errorf("failed to create destination file %s: %w", dstPath, err)
}
defer dstFile.Close()
// Copy the file contents
_, err = io.Copy(dstFile, srcFile)
if err != nil {
return fmt.Errorf("failed to copy file contents from %s to %s: %w", srcPath, dstPath, err)
}
}
return nil
})
return err
}
// format plugins dir name from dash to underline
func formatUIPluginsDirName(dirPath string) {
entries, err := os.ReadDir(dirPath)
if err != nil {
fmt.Printf("read ui plugins dir failed: [%s] %s\n", dirPath, err)
return
}
for _, entry := range entries {
if !entry.IsDir() || !strings.Contains(entry.Name(), "-") {
continue
}
newName := strings.ReplaceAll(entry.Name(), "-", "_")
if err := os.Rename(filepath.Join(dirPath, entry.Name()), filepath.Join(dirPath, newName)); err != nil {
fmt.Printf("rename ui plugins dir failed: [%s] %s\n", dirPath, err)
} else {
fmt.Printf("rename ui plugins dir success: [%s] -> [%s]\n", entry.Name(), newName)
}
}
}
// buildBinary build binary file
func buildBinary(b *buildingMaterial) (err error) {
versionInfo := b.originalAnswerInfo
cmdPkg := "github.com/apache/answer/cmd"
ldflags := fmt.Sprintf("-X %s.Version=%s -X %s.Revision=%s -X %s.Time=%s",
cmdPkg, versionInfo.Version, cmdPkg, versionInfo.Revision, cmdPkg, versionInfo.Time)
err = b.newExecCmd("go", "build",
"-ldflags", ldflags, "-o", b.outputPath, ".").Run()
if err != nil {
return err
}
return
}
// cleanByproduct delete tmp dir
func cleanByproduct(b *buildingMaterial) (err error) {
return os.RemoveAll(b.tmpDir)
}
func (b *buildingMaterial) newExecCmd(command string, args ...string) *exec.Cmd {
cmd := exec.Command(command, args...)
fmt.Println(cmd.Args)
cmd.Dir = b.tmpDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd
}
func versionedModulePath(modulePath, moduleVersion string) string {
if moduleVersion == "" {
return modulePath
}
ver, err := semver.StrictNewVersion(strings.TrimPrefix(moduleVersion, "v"))
if err != nil {
return modulePath
}
major := ver.Major()
if major > 1 {
modulePath += fmt.Sprintf("/v%d", major)
}
return path.Clean(modulePath)
}
+126
View File
@@ -0,0 +1,126 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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.
*/
package cli
import (
"context"
"encoding/json"
"fmt"
"github.com/apache/answer/internal/base/constant"
"github.com/apache/answer/internal/base/data"
"github.com/apache/answer/internal/entity"
"xorm.io/xorm"
)
type ConfigField struct {
AllowPasswordLogin bool `json:"allow_password_login"`
// The slug name of plugin that you want to deactivate
DeactivatePluginSlugName string `json:"deactivate_plugin_slug_name"`
}
// SetDefaultConfig set default config
func SetDefaultConfig(dbConf *data.Database, cacheConf *data.CacheConf, field *ConfigField) error {
db, err := data.NewDB(false, dbConf)
if err != nil {
return err
}
defer func() {
_ = db.Close()
}()
cache, cacheCleanup, err := data.NewCache(cacheConf)
if err != nil {
fmt.Println("new cache failed")
}
defer func() {
if cache != nil {
_ = cache.Flush(context.Background())
cacheCleanup()
}
}()
if field.AllowPasswordLogin {
return defaultLoginConfig(db)
}
if len(field.DeactivatePluginSlugName) > 0 {
return deactivatePlugin(db, field.DeactivatePluginSlugName)
}
return nil
}
func defaultLoginConfig(x *xorm.Engine) (err error) {
fmt.Println("set default login config")
loginSiteInfo := &entity.SiteInfo{
Type: constant.SiteTypeLogin,
}
exist, err := x.Get(loginSiteInfo)
if err != nil {
return fmt.Errorf("get config failed: %w", err)
}
if exist {
var content map[string]any
_ = json.Unmarshal([]byte(loginSiteInfo.Content), &content)
content["allow_password_login"] = true
dataByte, _ := json.Marshal(content)
loginSiteInfo.Content = string(dataByte)
_, err = x.ID(loginSiteInfo.ID).Cols("content").Update(loginSiteInfo)
if err != nil {
return fmt.Errorf("update site info failed: %w", err)
}
}
return nil
}
func deactivatePlugin(x *xorm.Engine, pluginSlugName string) (err error) {
fmt.Printf("try to deactivate plugin: %s\n", pluginSlugName)
item := &entity.Config{Key: constant.PluginStatus}
exist, err := x.Get(item)
if err != nil {
return fmt.Errorf("get config failed: %w", err)
}
if !exist {
return nil
}
pluginStatusMapping := make(map[string]bool)
_ = json.Unmarshal([]byte(item.Value), &pluginStatusMapping)
status, ok := pluginStatusMapping[pluginSlugName]
if !ok {
fmt.Printf("plugin %s not exist\n", pluginSlugName)
return nil
}
if !status {
fmt.Printf("plugin %s already deactivated\n", pluginSlugName)
return nil
}
pluginStatusMapping[pluginSlugName] = false
dataByte, _ := json.Marshal(pluginStatusMapping)
item.Value = string(dataByte)
_, err = x.ID(item.ID).Cols("value").Update(item)
if err != nil {
return fmt.Errorf("update plugin status failed: %w", err)
}
return nil
}
+46
View File
@@ -0,0 +1,46 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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.
*/
package cli
import (
"fmt"
"path/filepath"
"time"
"github.com/apache/answer/internal/base/data"
"xorm.io/xorm/schemas"
)
// DumpAllData dump all database data to sql
func DumpAllData(dataConf *data.Database, dumpDataPath string) error {
db, err := data.NewDB(false, dataConf)
if err != nil {
return err
}
defer func() {
_ = db.Close()
}()
if err = db.Ping(); err != nil {
return err
}
name := filepath.Join(dumpDataPath, fmt.Sprintf("answer_dump_data_%s.sql", time.Now().Format("2006-01-02")))
return db.DumpAllToFile(name, schemas.MYSQL)
}
+178
View File
@@ -0,0 +1,178 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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.
*/
package cli
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/apache/answer/i18n"
"github.com/apache/answer/pkg/dir"
"github.com/apache/answer/pkg/writer"
"gopkg.in/yaml.v3"
)
type YamlPluginContent struct {
Plugin map[string]any `yaml:"plugin"`
}
// ReplaceI18nFilesLocal replace i18n files
func ReplaceI18nFilesLocal(i18nDir string) error {
i18nList, err := i18n.I18n.ReadDir(".")
if err != nil {
fmt.Println(err.Error())
return err
}
fmt.Printf("[i18n] find i18n bundle %d\n", len(i18nList))
for _, item := range i18nList {
path := filepath.Join(i18nDir, item.Name())
content, err := i18n.I18n.ReadFile(item.Name())
if err != nil {
continue
}
exist := dir.CheckFileExist(path)
if exist {
fmt.Printf("[i18n] install %s file exist, try to replace it\n", item.Name())
if err = os.Remove(path); err != nil {
fmt.Println(err)
}
}
fmt.Printf("[i18n] install %s bundle...\n", item.Name())
err = writer.WriteFile(path, string(content))
if err != nil {
fmt.Printf("[i18n] install %s bundle fail: %s\n", item.Name(), err.Error())
} else {
fmt.Printf("[i18n] install %s bundle success\n", item.Name())
}
}
return nil
}
// MergeI18nFilesLocal merge i18n files
func MergeI18nFilesLocal(originalI18nDir, targetI18nDir string) (err error) {
pluginAllTranslations := make(map[string]*YamlPluginContent)
err = findI18nFileInDir(pluginAllTranslations, targetI18nDir)
if err != nil {
return err
}
entries, err := os.ReadDir(originalI18nDir)
if err != nil {
return err
}
for _, file := range entries {
// ignore directory
if file.IsDir() {
continue
}
// ignore non-YAML file
filename := file.Name()
if filepath.Ext(filename) != ".yaml" && filename != "i18n.yaml" {
continue
}
// if plugin don't have this translation file, ignore it
if pluginAllTranslations[filename] == nil {
continue
}
out, _ := yaml.Marshal(pluginAllTranslations[filename])
buf, err := os.OpenFile(filepath.Join(originalI18nDir, filename), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
fmt.Printf("[i18n] read translation file failed: %s %s\n", filename, err)
continue
}
_, _ = buf.WriteString("\n")
_, _ = buf.Write(out)
_ = buf.Close()
fmt.Printf("[i18n] merge i18n file: %s success\n", filename)
}
return nil
}
// find i18n file in dir
func findI18nFileInDir(pluginAllTranslations map[string]*YamlPluginContent, i18nDir string) error {
// if i18n dir is not i18n, find deeper
dirBase := filepath.Base(i18nDir)
if dirBase != "i18n" {
if strings.HasPrefix(dirBase, ".") {
return nil
}
// find all i18n dir in target dir
targetDirs, err := os.ReadDir(i18nDir)
if err != nil {
return err
}
for _, targetDir := range targetDirs {
if targetDir.IsDir() {
if err := findI18nFileInDir(pluginAllTranslations, filepath.Join(i18nDir, targetDir.Name())); err != nil {
fmt.Printf("[i18n] find i18n file in dir failed: %s %s\n", targetDir.Name(), err)
}
}
}
return nil
}
fmt.Printf("[i18n] find i18n file in dir: %s\n", i18nDir)
// if i18nDir is i18n, find all yaml files
entries, err := os.ReadDir(i18nDir)
if err != nil {
return err
}
for _, file := range entries {
// ignore directory
if file.IsDir() {
continue
}
// ignore non-YAML file
if filepath.Ext(file.Name()) != ".yaml" {
continue
}
buf, err := os.ReadFile(filepath.Join(i18nDir, file.Name()))
if err != nil {
fmt.Printf("[i18n] read translation file failed: %s %s\n", file.Name(), err)
continue
}
translation := &YamlPluginContent{}
if err = yaml.Unmarshal(buf, translation); err != nil {
fmt.Printf("[i18n] unmarshal translation file failed: %s %s\n", file.Name(), err)
continue
}
if pluginAllTranslations[file.Name()] == nil {
pluginAllTranslations[file.Name()] = &YamlPluginContent{Plugin: make(map[string]any)}
}
for k, v := range translation.Plugin {
pluginAllTranslations[file.Name()].Plugin[k] = v
}
}
return nil
}
+118
View File
@@ -0,0 +1,118 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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.
*/
package cli
import (
"fmt"
"os"
"path/filepath"
"github.com/apache/answer/configs"
"github.com/apache/answer/i18n"
"github.com/apache/answer/internal/base/path"
"github.com/apache/answer/pkg/dir"
"github.com/apache/answer/pkg/writer"
)
// InstallAllInitialEnvironment install all initial environment
func InstallAllInitialEnvironment(dataDirPath string) {
path.FormatAllPath(dataDirPath)
installUploadDir()
InstallI18nBundle(false)
fmt.Println("install all initial environment done")
}
func InstallConfigFile(configFilePath string) error {
if len(configFilePath) == 0 {
configFilePath = filepath.Join(path.ConfigFileDir, path.DefaultConfigFileName)
}
fmt.Println("[config-file] try to create at ", configFilePath)
// if config file already exists do nothing.
if CheckConfigFile(configFilePath) {
fmt.Printf("[config-file] %s already exists\n", configFilePath)
return nil
}
if err := dir.CreateDirIfNotExist(path.ConfigFileDir); err != nil {
fmt.Printf("[config-file] create directory fail %s\n", err.Error())
return fmt.Errorf("create directory fail %s", err.Error())
}
fmt.Printf("[config-file] create directory success, config file is %s\n", configFilePath)
if err := writer.WriteFile(configFilePath, string(configs.Config)); err != nil {
fmt.Printf("[config-file] install fail %s\n", err.Error())
return fmt.Errorf("write file failed %s", err)
}
fmt.Printf("[config-file] install success\n")
return nil
}
func installUploadDir() {
fmt.Println("[upload-dir] try to install...")
if err := dir.CreateDirIfNotExist(path.UploadFilePath); err != nil {
fmt.Printf("[upload-dir] install fail %s\n", err.Error())
} else {
fmt.Printf("[upload-dir] install success, upload directory is %s\n", path.UploadFilePath)
}
}
func InstallI18nBundle(replace bool) {
fmt.Println("[i18n] try to install i18n bundle...")
// if SKIP_REPLACE_I18N is set, skip replace i18n bundles
if len(os.Getenv("SKIP_REPLACE_I18N")) > 0 {
replace = false
}
if err := dir.CreateDirIfNotExist(path.I18nPath); err != nil {
fmt.Println(err.Error())
return
}
i18nList, err := i18n.I18n.ReadDir(".")
if err != nil {
fmt.Println(err.Error())
return
}
fmt.Printf("[i18n] find i18n bundle %d\n", len(i18nList))
for _, item := range i18nList {
path := filepath.Join(path.I18nPath, item.Name())
content, err := i18n.I18n.ReadFile(item.Name())
if err != nil {
continue
}
exist := dir.CheckFileExist(path)
if exist && !replace {
continue
}
if exist {
fmt.Printf("[i18n] install %s file exist, try to replace it\n", item.Name())
if err = os.Remove(path); err != nil {
fmt.Println(err)
}
}
fmt.Printf("[i18n] install %s bundle...\n", item.Name())
err = writer.WriteFile(path, string(content))
if err != nil {
fmt.Printf("[i18n] install %s bundle fail: %s\n", item.Name(), err.Error())
} else {
fmt.Printf("[i18n] install %s bundle success\n", item.Name())
}
}
}
+82
View File
@@ -0,0 +1,82 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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.
*/
package cli
import (
"fmt"
"github.com/apache/answer/internal/base/data"
"github.com/apache/answer/internal/base/path"
"github.com/apache/answer/internal/entity"
"github.com/apache/answer/pkg/dir"
)
func CheckConfigFile(configPath string) bool {
return dir.CheckFileExist(configPath)
}
func CheckUploadDir() bool {
return dir.CheckDirExist(path.UploadFilePath)
}
// CheckDBConnection check database whether the connection is normal
func CheckDBConnection(dataConf *data.Database) bool {
db, err := data.NewDB(false, dataConf)
if err != nil {
fmt.Printf("connection database failed: %s\n", err)
return false
}
defer func() {
_ = db.Close()
}()
if err = db.Ping(); err != nil {
fmt.Printf("connection ping database failed: %s\n", err)
return false
}
return true
}
// CheckDBTableExist check database whether the table is already exists
func CheckDBTableExist(dataConf *data.Database) bool {
db, err := data.NewDB(false, dataConf)
if err != nil {
fmt.Printf("connection database failed: %s\n", err)
return false
}
defer func() {
_ = db.Close()
}()
if err = db.Ping(); err != nil {
fmt.Printf("connection ping database failed: %s\n", err)
return false
}
exist, err := db.IsTableExist(&entity.Version{})
if err != nil {
fmt.Printf("check table exist failed: %s\n", err)
return false
}
if !exist {
fmt.Printf("check table not exist\n")
return false
}
return true
}
+292
View File
@@ -0,0 +1,292 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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.
*/
package cli
import (
"bufio"
"context"
"crypto/rand"
"fmt"
"math/big"
"os"
"runtime"
"strings"
"github.com/apache/answer/internal/base/conf"
"github.com/apache/answer/internal/base/data"
"github.com/apache/answer/internal/base/path"
"github.com/apache/answer/internal/repo/api_key"
"github.com/apache/answer/internal/repo/auth"
"github.com/apache/answer/internal/repo/user"
authService "github.com/apache/answer/internal/service/auth"
"github.com/apache/answer/pkg/checker"
_ "github.com/go-sql-driver/mysql"
_ "github.com/lib/pq"
"golang.org/x/crypto/bcrypt"
"golang.org/x/term"
_ "modernc.org/sqlite"
"xorm.io/xorm"
)
const (
charsetLower = "abcdefghijklmnopqrstuvwxyz"
charsetUpper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
charsetDigits = "0123456789"
charsetSpecial = "!@#$%^&*~?_-"
maxRetries = 10
defaultRandomPasswordLength = 12
)
var charset = []string{
charsetLower,
charsetUpper,
charsetDigits,
charsetSpecial,
}
type ResetPasswordOptions struct {
Email string
Password string
}
func ResetPassword(ctx context.Context, dataDirPath string, opts *ResetPasswordOptions) error {
path.FormatAllPath(dataDirPath)
config, err := conf.ReadConfig(path.GetConfigFilePath())
if err != nil {
return fmt.Errorf("read config file failed: %w", err)
}
db, err := initDatabase(config.Data.Database.Driver, config.Data.Database.Connection)
if err != nil {
return fmt.Errorf("connect database failed: %w", err)
}
defer func() {
_ = db.Close()
}()
cache, cacheCleanup, err := data.NewCache(config.Data.Cache)
if err != nil {
return fmt.Errorf("initialize cache failed: %w", err)
}
defer cacheCleanup()
dataData, dataCleanup, err := data.NewData(db, cache)
if err != nil {
return fmt.Errorf("initialize data layer failed: %w", err)
}
defer dataCleanup()
userRepo := user.NewUserRepo(dataData)
authRepo := auth.NewAuthRepo(dataData)
apiKeyRepo := api_key.NewAPIKeyRepo(dataData)
authSvc := authService.NewAuthService(authRepo, apiKeyRepo)
email := strings.TrimSpace(opts.Email)
if email == "" {
reader := bufio.NewReader(os.Stdin)
fmt.Print("Please input user email: ")
emailInput, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("read email input failed: %w", err)
}
email = strings.TrimSpace(emailInput)
}
userInfo, exist, err := userRepo.GetByEmail(ctx, email)
if err != nil {
return fmt.Errorf("query user failed: %w", err)
}
if !exist {
return fmt.Errorf("user not found: %s", email)
}
fmt.Printf("You are going to reset password for user: %s\n", email)
password := strings.TrimSpace(opts.Password)
if password != "" {
printWarning("Passing password via command line may be recorded in shell history")
if err := checker.CheckPassword(password); err != nil {
return fmt.Errorf("password validation failed: %w", err)
}
} else {
password, err = promptForPassword()
if err != nil {
return fmt.Errorf("password input failed: %w", err)
}
}
if !confirmAction(fmt.Sprintf("This will reset password for user '[%s]%s'. Continue?", userInfo.DisplayName, email)) {
fmt.Println("Operation cancelled")
return nil
}
hashPwd, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("encrypt password failed: %w", err)
}
if err = userRepo.UpdatePass(ctx, userInfo.ID, string(hashPwd)); err != nil {
return fmt.Errorf("update password failed: %w", err)
}
authSvc.RemoveUserAllTokens(ctx, userInfo.ID)
fmt.Printf("Password has been successfully updated for user: %s\n", email)
fmt.Println("All login sessions have been cleared")
return nil
}
// promptForPassword prompts for a password
func promptForPassword() (string, error) {
for {
input, err := getPasswordInput("Please input new password (empty to generate random password): ")
if err != nil {
return "", err
}
if input == "" {
password, err := generateRandomPasswordWithRetry()
if err != nil {
return "", fmt.Errorf("generate random password failed: %w", err)
}
fmt.Printf("Generated random password: %s\n", password)
fmt.Println("Please save this password in a secure location")
return password, nil
}
if err := checker.CheckPassword(input); err != nil {
fmt.Printf("Password validation failed: %v\n", err)
fmt.Println("Please try again")
continue
}
confirmPwd, err := getPasswordInput("Please confirm new password: ")
if err != nil {
return "", err
}
if input != confirmPwd {
fmt.Println("Passwords do not match, please try again")
continue
}
return input, nil
}
}
func generateRandomPasswordWithRetry() (string, error) {
var password string
var err error
for range maxRetries {
password, err = generateRandomPassword(defaultRandomPasswordLength)
if err != nil {
continue
}
if err := checker.CheckPassword(password); err == nil {
return password, nil
}
}
if err != nil {
return "", err
}
return "", fmt.Errorf("failed to generate valid password after %d retries", maxRetries)
}
func getPasswordInput(prompt string) (string, error) {
fmt.Print(prompt)
password, err := term.ReadPassword(int(os.Stdin.Fd()))
if err != nil {
return "", err
}
fmt.Println()
return string(password), nil
}
func generateRandomPassword(length int) (string, error) {
if length < len(charset) {
return "", fmt.Errorf("password length must be at least %d", len(charset))
}
bytes := make([]byte, length)
for i, charsetItem := range charset {
charIndex, err := rand.Int(rand.Reader, big.NewInt(int64(len(charsetItem))))
if err != nil {
return "", err
}
bytes[i] = charsetItem[charIndex.Int64()]
}
fullCharset := strings.Join(charset, "")
for i := len(charset); i < length; i++ {
charIndex, err := rand.Int(rand.Reader, big.NewInt(int64(len(fullCharset))))
if err != nil {
return "", err
}
bytes[i] = fullCharset[charIndex.Int64()]
}
for i := len(bytes) - 1; i > 0; i-- {
j, err := rand.Int(rand.Reader, big.NewInt(int64(i+1)))
if err != nil {
return "", err
}
bytes[i], bytes[j.Int64()] = bytes[j.Int64()], bytes[i]
}
return string(bytes), nil
}
func initDatabase(driver, connection string) (*xorm.Engine, error) {
dataConf := &data.Database{Driver: driver, Connection: connection}
if !CheckDBConnection(dataConf) {
return nil, fmt.Errorf("database connection check failed")
}
engine, err := data.NewDB(false, dataConf)
if err != nil {
return nil, err
}
return engine, nil
}
func printWarning(msg string) {
if runtime.GOOS == "windows" {
fmt.Printf("[WARNING] %s\n", msg)
} else {
fmt.Printf("\033[31m[WARNING] %s\033[0m\n", msg)
}
}
func confirmAction(prompt string) bool {
reader := bufio.NewReader(os.Stdin)
fmt.Printf("%s [y/N]: ", prompt)
response, err := reader.ReadString('\n')
if err != nil {
return false
}
response = strings.ToLower(strings.TrimSpace(response))
return response == "y" || response == "yes"
}