mirror of
https://github.com/lordmathis/lemma.git
synced 2025-11-06 07:54:22 +00:00
Rename filesystem interfaces and structs
This commit is contained in:
271
server/internal/storage/files.go
Normal file
271
server/internal/storage/files.go
Normal file
@@ -0,0 +1,271 @@
|
||||
// Package storage provides functionalities to interact with the file system,
|
||||
// including listing files, finding files by name, getting file content, saving files, and deleting files.
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FileManager provides functionalities to interact with files in the storage.
|
||||
type FileManager interface {
|
||||
ListFilesRecursively(userID, workspaceID int) ([]FileNode, error)
|
||||
FindFileByName(userID, workspaceID int, filename string) ([]string, error)
|
||||
GetFileContent(userID, workspaceID int, filePath string) ([]byte, error)
|
||||
SaveFile(userID, workspaceID int, filePath string, content []byte) error
|
||||
DeleteFile(userID, workspaceID int, filePath string) error
|
||||
GetFileStats(userID, workspaceID int) (*FileCountStats, error)
|
||||
GetTotalFileStats() (*FileCountStats, error)
|
||||
}
|
||||
|
||||
// FileNode represents a file or directory in the storage.
|
||||
type FileNode struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Children []FileNode `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
// ListFilesRecursively returns a list of all files in the workspace directory and its subdirectories.
|
||||
// Parameters:
|
||||
// - userID: the ID of the user who owns the workspace
|
||||
// - workspaceID: the ID of the workspace to list files in
|
||||
// Returns:
|
||||
// - nodes: a list of files and directories in the workspace
|
||||
// - error: any error that occurred during listing
|
||||
func (s *Service) ListFilesRecursively(userID, workspaceID int) ([]FileNode, error) {
|
||||
workspacePath := s.GetWorkspacePath(userID, workspaceID)
|
||||
return s.walkDirectory(workspacePath, "")
|
||||
}
|
||||
|
||||
// walkDirectory recursively walks the directory and returns a list of files and directories.
|
||||
func (s *Service) walkDirectory(dir, prefix string) ([]FileNode, error) {
|
||||
entries, err := s.fs.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Split entries into directories and files
|
||||
var dirs, files []os.DirEntry
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
dirs = append(dirs, entry)
|
||||
} else {
|
||||
files = append(files, entry)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort directories and files separately
|
||||
sort.Slice(dirs, func(i, j int) bool {
|
||||
return strings.ToLower(dirs[i].Name()) < strings.ToLower(dirs[j].Name())
|
||||
})
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
return strings.ToLower(files[i].Name()) < strings.ToLower(files[j].Name())
|
||||
})
|
||||
|
||||
// Create combined slice with directories first, then files
|
||||
nodes := make([]FileNode, 0, len(entries))
|
||||
|
||||
// Add directories first
|
||||
for _, entry := range dirs {
|
||||
name := entry.Name()
|
||||
path := filepath.Join(prefix, name)
|
||||
fullPath := filepath.Join(dir, name)
|
||||
|
||||
children, err := s.walkDirectory(fullPath, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
node := FileNode{
|
||||
ID: path,
|
||||
Name: name,
|
||||
Path: path,
|
||||
Children: children,
|
||||
}
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
|
||||
// Then add files
|
||||
for _, entry := range files {
|
||||
name := entry.Name()
|
||||
path := filepath.Join(prefix, name)
|
||||
|
||||
node := FileNode{
|
||||
ID: path,
|
||||
Name: name,
|
||||
Path: path,
|
||||
}
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
// FindFileByName returns a list of file paths that match the given filename.
|
||||
// Parameters:
|
||||
// - userID: the ID of the user who owns the workspace
|
||||
// - workspaceID: the ID of the workspace to search for the file
|
||||
// - filename: the name of the file to search for
|
||||
// Returns:
|
||||
// - foundPaths: a list of file paths that match the filename
|
||||
// - error: any error that occurred during the search
|
||||
func (s *Service) FindFileByName(userID, workspaceID int, filename string) ([]string, error) {
|
||||
var foundPaths []string
|
||||
workspacePath := s.GetWorkspacePath(userID, workspaceID)
|
||||
|
||||
err := filepath.Walk(workspacePath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
relPath, err := filepath.Rel(workspacePath, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.EqualFold(info.Name(), filename) {
|
||||
foundPaths = append(foundPaths, relPath)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(foundPaths) == 0 {
|
||||
return nil, fmt.Errorf("file not found")
|
||||
}
|
||||
|
||||
return foundPaths, nil
|
||||
}
|
||||
|
||||
// GetFileContent returns the content of the file at the given path.
|
||||
// Parameters:
|
||||
// - userID: the ID of the user who owns the workspace
|
||||
// - workspaceID: the ID of the workspace to get the file from
|
||||
// - filePath: the path of the file to get
|
||||
// Returns:
|
||||
// - content: the content of the file
|
||||
// - error: any error that occurred during reading
|
||||
func (s *Service) GetFileContent(userID, workspaceID int, filePath string) ([]byte, error) {
|
||||
fullPath, err := s.ValidatePath(userID, workspaceID, filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.fs.ReadFile(fullPath)
|
||||
}
|
||||
|
||||
// SaveFile writes the content to the file at the given path.
|
||||
// Parameters:
|
||||
// - userID: the ID of the user who owns the workspace
|
||||
// - workspaceID: the ID of the workspace to save the file to
|
||||
// - filePath: the path of the file to save
|
||||
// - content: the content to write to the file
|
||||
// Returns:
|
||||
// - error: any error that occurred during saving
|
||||
func (s *Service) SaveFile(userID, workspaceID int, filePath string, content []byte) error {
|
||||
fullPath, err := s.ValidatePath(userID, workspaceID, filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dir := filepath.Dir(fullPath)
|
||||
if err := s.fs.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.fs.WriteFile(fullPath, content, 0644)
|
||||
}
|
||||
|
||||
// DeleteFile deletes the file at the given path.
|
||||
// Parameters:
|
||||
// - userID: the ID of the user who owns the workspace
|
||||
// - workspaceID: the ID of the workspace to delete the file from
|
||||
// - filePath: the path of the file to delete
|
||||
// Returns:
|
||||
// - error: any error that occurred during deletion
|
||||
func (s *Service) DeleteFile(userID, workspaceID int, filePath string) error {
|
||||
fullPath, err := s.ValidatePath(userID, workspaceID, filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.fs.Remove(fullPath)
|
||||
}
|
||||
|
||||
// FileCountStats holds statistics about files in a workspace
|
||||
type FileCountStats struct {
|
||||
TotalFiles int `json:"totalFiles"`
|
||||
TotalSize int64 `json:"totalSize"`
|
||||
}
|
||||
|
||||
// GetFileStats returns the total number of files and related statistics in a workspace
|
||||
// Parameters:
|
||||
// - userID: the ID of the user who owns the workspace
|
||||
// - workspaceID: the ID of the workspace to count files in
|
||||
// Returns:
|
||||
// - result: statistics about the files in the workspace
|
||||
// - error: any error that occurred during counting
|
||||
func (s *Service) GetFileStats(userID, workspaceID int) (*FileCountStats, error) {
|
||||
workspacePath := s.GetWorkspacePath(userID, workspaceID)
|
||||
|
||||
// Check if workspace exists
|
||||
if _, err := s.fs.Stat(workspacePath); s.fs.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("workspace directory does not exist")
|
||||
}
|
||||
|
||||
return s.countFilesInPath(workspacePath)
|
||||
|
||||
}
|
||||
|
||||
// GetTotalFileStats returns the total file statistics for the storage.
|
||||
// Returns:
|
||||
// - result: statistics about the files in the storage
|
||||
func (s *Service) GetTotalFileStats() (*FileCountStats, error) {
|
||||
return s.countFilesInPath(s.RootDir)
|
||||
}
|
||||
|
||||
// countFilesInPath counts the total number of files and the total size of files in the given directory.
|
||||
func (s *Service) countFilesInPath(directoryPath string) (*FileCountStats, error) {
|
||||
result := &FileCountStats{}
|
||||
|
||||
err := filepath.WalkDir(directoryPath, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip the .git directory
|
||||
if d.IsDir() && d.Name() == ".git" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
// Only count regular files
|
||||
if !d.IsDir() {
|
||||
// Get relative path from workspace root
|
||||
relPath, err := filepath.Rel(directoryPath, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get relative path: %w", err)
|
||||
}
|
||||
|
||||
// Get file info for size
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get file info for %s: %w", relPath, err)
|
||||
}
|
||||
|
||||
result.TotalFiles++
|
||||
result.TotalSize += info.Size()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error counting files: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
47
server/internal/storage/filesystem.go
Normal file
47
server/internal/storage/filesystem.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
)
|
||||
|
||||
// fileSystem defines the interface for filesystem operations
|
||||
type fileSystem interface {
|
||||
ReadFile(path string) ([]byte, error)
|
||||
WriteFile(path string, data []byte, perm fs.FileMode) error
|
||||
Remove(path string) error
|
||||
MkdirAll(path string, perm fs.FileMode) error
|
||||
RemoveAll(path string) error
|
||||
ReadDir(path string) ([]fs.DirEntry, error)
|
||||
Stat(path string) (fs.FileInfo, error)
|
||||
IsNotExist(err error) bool
|
||||
}
|
||||
|
||||
// osFS implements the FileSystem interface using the real filesystem.
|
||||
type osFS struct{}
|
||||
|
||||
// ReadFile reads the file at the given path.
|
||||
func (f *osFS) ReadFile(path string) ([]byte, error) { return os.ReadFile(path) }
|
||||
|
||||
// WriteFile writes the given data to the file at the given path.
|
||||
func (f *osFS) WriteFile(path string, data []byte, perm fs.FileMode) error {
|
||||
return os.WriteFile(path, data, perm)
|
||||
}
|
||||
|
||||
// Remove deletes the file at the given path.
|
||||
func (f *osFS) Remove(path string) error { return os.Remove(path) }
|
||||
|
||||
// MkdirAll creates the directory at the given path and any necessary parents.
|
||||
func (f *osFS) MkdirAll(path string, perm fs.FileMode) error { return os.MkdirAll(path, perm) }
|
||||
|
||||
// RemoveAll removes the file or directory at the given path.
|
||||
func (f *osFS) RemoveAll(path string) error { return os.RemoveAll(path) }
|
||||
|
||||
// ReadDir reads the directory at the given path.
|
||||
func (f *osFS) ReadDir(path string) ([]fs.DirEntry, error) { return os.ReadDir(path) }
|
||||
|
||||
// Stat returns the FileInfo for the file at the given path.
|
||||
func (f *osFS) Stat(path string) (fs.FileInfo, error) { return os.Stat(path) }
|
||||
|
||||
// IsNotExist returns true if the error is a "file does not exist" error.
|
||||
func (f *osFS) IsNotExist(err error) bool { return os.IsNotExist(err) }
|
||||
46
server/internal/storage/filesystem_test.go
Normal file
46
server/internal/storage/filesystem_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package storage_test
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
// mapFS adapts testing.MapFS to implement our fileSystem interface
|
||||
type mapFS struct {
|
||||
fstest.MapFS
|
||||
}
|
||||
|
||||
func NewMapFS() *mapFS {
|
||||
return &mapFS{
|
||||
MapFS: make(fstest.MapFS),
|
||||
}
|
||||
}
|
||||
|
||||
// Only implement the methods that MapFS doesn't already provide
|
||||
func (m *mapFS) WriteFile(path string, data []byte, perm fs.FileMode) error {
|
||||
m.MapFS[path] = &fstest.MapFile{
|
||||
Data: data,
|
||||
Mode: perm,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mapFS) Remove(path string) error {
|
||||
delete(m.MapFS, path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mapFS) MkdirAll(_ string, _ fs.FileMode) error {
|
||||
// For MapFS, we don't actually need to create directories
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mapFS) RemoveAll(path string) error {
|
||||
delete(m.MapFS, path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mapFS) IsNotExist(err error) bool {
|
||||
return os.IsNotExist(err)
|
||||
}
|
||||
90
server/internal/storage/git.go
Normal file
90
server/internal/storage/git.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"novamd/internal/gitutils"
|
||||
)
|
||||
|
||||
// RepositoryManager defines the interface for managing Git repositories.
|
||||
type RepositoryManager interface {
|
||||
SetupGitRepo(userID, workspaceID int, gitURL, gitUser, gitToken string) error
|
||||
DisableGitRepo(userID, workspaceID int)
|
||||
StageCommitAndPush(userID, workspaceID int, message string) error
|
||||
Pull(userID, workspaceID int) error
|
||||
}
|
||||
|
||||
// SetupGitRepo sets up a Git repository for the given user and workspace IDs.
|
||||
// Parameters:
|
||||
// - userID: the ID of the user who owns the workspace
|
||||
// - workspaceID: the ID of the workspace to set up the Git repository for
|
||||
// - gitURL: the URL of the Git repository
|
||||
// - gitUser: the username for the Git repository
|
||||
// - gitToken: the access token for the Git repository
|
||||
// Returns:
|
||||
// - error: any error that occurred during setup
|
||||
func (s *Service) SetupGitRepo(userID, workspaceID int, gitURL, gitUser, gitToken string) error {
|
||||
workspacePath := s.GetWorkspacePath(userID, workspaceID)
|
||||
if _, ok := s.GitRepos[userID]; !ok {
|
||||
s.GitRepos[userID] = make(map[int]*gitutils.GitRepo)
|
||||
}
|
||||
s.GitRepos[userID][workspaceID] = gitutils.New(gitURL, gitUser, gitToken, workspacePath)
|
||||
return s.GitRepos[userID][workspaceID].EnsureRepo()
|
||||
}
|
||||
|
||||
// DisableGitRepo disables the Git repository for the given user and workspace IDs.
|
||||
// Parameters:
|
||||
// - userID: the ID of the user who owns the workspace
|
||||
// - workspaceID: the ID of the workspace to disable the Git repository for
|
||||
func (s *Service) DisableGitRepo(userID, workspaceID int) {
|
||||
if userRepos, ok := s.GitRepos[userID]; ok {
|
||||
delete(userRepos, workspaceID)
|
||||
if len(userRepos) == 0 {
|
||||
delete(s.GitRepos, userID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StageCommitAndPush stages, commits, and pushes the changes to the Git repository.
|
||||
// Parameters:
|
||||
// - userID: the ID of the user who owns the workspace
|
||||
// - workspaceID: the ID of the workspace to commit and push
|
||||
// - message: the commit message
|
||||
// Returns:
|
||||
// - error: any error that occurred during the operation
|
||||
func (s *Service) StageCommitAndPush(userID, workspaceID int, message string) error {
|
||||
repo, ok := s.getGitRepo(userID, workspaceID)
|
||||
if !ok {
|
||||
return fmt.Errorf("git settings not configured for this workspace")
|
||||
}
|
||||
|
||||
if err := repo.Commit(message); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return repo.Push()
|
||||
}
|
||||
|
||||
// Pull pulls the changes from the remote Git repository.
|
||||
// Parameters:
|
||||
// - userID: the ID of the user who owns the workspace
|
||||
// - workspaceID: the ID of the workspace to pull changes for
|
||||
// Returns:
|
||||
// - error: any error that occurred during the operation
|
||||
func (s *Service) Pull(userID, workspaceID int) error {
|
||||
repo, ok := s.getGitRepo(userID, workspaceID)
|
||||
if !ok {
|
||||
return fmt.Errorf("git settings not configured for this workspace")
|
||||
}
|
||||
|
||||
return repo.Pull()
|
||||
}
|
||||
|
||||
// getGitRepo returns the Git repository for the given user and workspace IDs.
|
||||
func (s *Service) getGitRepo(userID, workspaceID int) (*gitutils.GitRepo, bool) {
|
||||
userRepos, ok := s.GitRepos[userID]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
repo, ok := userRepos[workspaceID]
|
||||
return repo, ok
|
||||
}
|
||||
42
server/internal/storage/service.go
Normal file
42
server/internal/storage/service.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"novamd/internal/gitutils"
|
||||
)
|
||||
|
||||
// Manager interface combines all storage interfaces.
|
||||
type Manager interface {
|
||||
FileManager
|
||||
WorkspaceManager
|
||||
RepositoryManager
|
||||
}
|
||||
|
||||
// Service represents the file system structure.
|
||||
type Service struct {
|
||||
fs fileSystem
|
||||
RootDir string
|
||||
GitRepos map[int]map[int]*gitutils.GitRepo // map[userID]map[workspaceID]*gitutils.GitRepo
|
||||
}
|
||||
|
||||
// NewService creates a new Storage instance.
|
||||
// Parameters:
|
||||
// - rootDir: the root directory for the storage
|
||||
// Returns:
|
||||
// - result: the new Storage instance
|
||||
func NewService(rootDir string) *Service {
|
||||
return NewServiceWithFS(rootDir, &osFS{})
|
||||
}
|
||||
|
||||
// NewServiceWithFS creates a new Storage instance with the given filesystem.
|
||||
// Parameters:
|
||||
// - rootDir: the root directory for the storage
|
||||
// - fs: the filesystem implementation to use
|
||||
// Returns:
|
||||
// - result: the new Storage instance
|
||||
func NewServiceWithFS(rootDir string, fs fileSystem) *Service {
|
||||
return &Service{
|
||||
fs: fs,
|
||||
RootDir: rootDir,
|
||||
GitRepos: make(map[int]map[int]*gitutils.GitRepo),
|
||||
}
|
||||
}
|
||||
77
server/internal/storage/workspace.go
Normal file
77
server/internal/storage/workspace.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// WorkspaceManager provides functionalities to interact with workspaces in the storage.
|
||||
type WorkspaceManager interface {
|
||||
ValidatePath(userID, workspaceID int, path string) (string, error)
|
||||
GetWorkspacePath(userID, workspaceID int) string
|
||||
InitializeUserWorkspace(userID, workspaceID int) error
|
||||
DeleteUserWorkspace(userID, workspaceID int) error
|
||||
}
|
||||
|
||||
// ValidatePath validates the given path and returns the cleaned path if it is valid.
|
||||
// Parameters:
|
||||
// - userID: the ID of the user who owns the workspace
|
||||
// - workspaceID: the ID of the workspace to validate the path for
|
||||
// - path: the path to validate
|
||||
// Returns:
|
||||
// - result: the cleaned path if it is valid
|
||||
// - error: any error that occurred during validation
|
||||
func (s *Service) ValidatePath(userID, workspaceID int, path string) (string, error) {
|
||||
workspacePath := s.GetWorkspacePath(userID, workspaceID)
|
||||
fullPath := filepath.Join(workspacePath, path)
|
||||
cleanPath := filepath.Clean(fullPath)
|
||||
|
||||
if !strings.HasPrefix(cleanPath, workspacePath) {
|
||||
return "", fmt.Errorf("invalid path: outside of workspace")
|
||||
}
|
||||
|
||||
return cleanPath, nil
|
||||
}
|
||||
|
||||
// GetWorkspacePath returns the path to the workspace directory for the given user and workspace IDs.
|
||||
// Parameters:
|
||||
// - userID: the ID of the user who owns the workspace
|
||||
// - workspaceID: the ID of the workspace
|
||||
// Returns:
|
||||
// - result: the path to the workspace directory
|
||||
func (s *Service) GetWorkspacePath(userID, workspaceID int) string {
|
||||
return filepath.Join(s.RootDir, fmt.Sprintf("%d", userID), fmt.Sprintf("%d", workspaceID))
|
||||
}
|
||||
|
||||
// InitializeUserWorkspace creates the workspace directory for the given user and workspace IDs.
|
||||
// Parameters:
|
||||
// - userID: the ID of the user who owns the workspace
|
||||
// - workspaceID: the ID of the workspace to initialize
|
||||
// Returns:
|
||||
// - error: any error that occurred during the operation
|
||||
func (s *Service) InitializeUserWorkspace(userID, workspaceID int) error {
|
||||
workspacePath := s.GetWorkspacePath(userID, workspaceID)
|
||||
err := s.fs.MkdirAll(workspacePath, 0755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create workspace directory: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUserWorkspace deletes the workspace directory for the given user and workspace IDs.
|
||||
// Parameters:
|
||||
// - userID: the ID of the user who owns the workspace
|
||||
// - workspaceID: the ID of the workspace to delete
|
||||
// Returns:
|
||||
// - error: any error that occurred during the operation
|
||||
func (s *Service) DeleteUserWorkspace(userID, workspaceID int) error {
|
||||
workspacePath := s.GetWorkspacePath(userID, workspaceID)
|
||||
err := s.fs.RemoveAll(workspacePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete workspace directory: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user