package workspace import ( "archive/tar" "archive/zip" "compress/gzip" "fmt" "io" "os" "path/filepath" "strings" ) // Manager handles workspace directories for projects. type Manager struct { BaseDir string } // New creates a new workspace Manager. func New(baseDir string) (*Manager, error) { if err := os.MkdirAll(baseDir, 0755); err != nil { return nil, err } return &Manager{BaseDir: baseDir}, nil } // ProjectDir returns the root workspace directory for a project. func (m *Manager) ProjectDir(projectID uint) string { return filepath.Join(m.BaseDir, fmt.Sprintf("project-%d", projectID)) } // SrcDir returns the source directory for a project. func (m *Manager) SrcDir(projectID uint) string { return filepath.Join(m.ProjectDir(projectID), "src") } // BuildsDir returns the builds directory for a project. func (m *Manager) BuildsDir(projectID uint) string { return filepath.Join(m.ProjectDir(projectID), "builds") } // LogsDir returns the logs directory for a project. func (m *Manager) LogsDir(projectID uint) string { return filepath.Join(m.ProjectDir(projectID), "logs") } // InitProject creates all required directories for a project. func (m *Manager) InitProject(projectID uint) error { dirs := []string{ m.SrcDir(projectID), m.BuildsDir(projectID), m.LogsDir(projectID), } for _, d := range dirs { if err := os.MkdirAll(d, 0755); err != nil { return err } } return nil } // CleanProject removes the entire project workspace. func (m *Manager) CleanProject(projectID uint) error { return os.RemoveAll(m.ProjectDir(projectID)) } // ExtractArchive detects archive type and extracts to the project src directory. // Supports .zip, .tar.gz, .tgz. func (m *Manager) ExtractArchive(projectID uint, filePath string) error { dest := m.SrcDir(projectID) // Clean destination first os.RemoveAll(dest) os.MkdirAll(dest, 0755) lower := strings.ToLower(filePath) switch { case strings.HasSuffix(lower, ".zip"): return extractZip(filePath, dest) case strings.HasSuffix(lower, ".tar.gz"), strings.HasSuffix(lower, ".tgz"): return extractTarGz(filePath, dest) default: return fmt.Errorf("unsupported archive format: %s (use .zip or .tar.gz)", filePath) } } func extractZip(src, dest string) error { r, err := zip.OpenReader(src) if err != nil { return err } defer r.Close() for _, f := range r.File { // Prevent zip slip path := filepath.Join(dest, f.Name) if !strings.HasPrefix(filepath.Clean(path), filepath.Clean(dest)+string(os.PathSeparator)) { return fmt.Errorf("illegal file path in zip: %s", f.Name) } if f.FileInfo().IsDir() { os.MkdirAll(path, 0755) continue } os.MkdirAll(filepath.Dir(path), 0755) out, err := os.Create(path) if err != nil { return err } rc, err := f.Open() if err != nil { out.Close() return err } _, err = io.Copy(out, rc) rc.Close() out.Close() if err != nil { return err } } return nil } func extractTarGz(src, dest string) error { f, err := os.Open(src) if err != nil { return err } defer f.Close() gzReader, err := gzip.NewReader(f) if err != nil { return err } defer gzReader.Close() tarReader := tar.NewReader(gzReader) for { header, err := tarReader.Next() if err == io.EOF { break } if err != nil { return err } path := filepath.Join(dest, header.Name) if !strings.HasPrefix(filepath.Clean(path), filepath.Clean(dest)+string(os.PathSeparator)) { return fmt.Errorf("illegal file path in tar: %s", header.Name) } switch header.Typeflag { case tar.TypeDir: os.MkdirAll(path, 0755) case tar.TypeReg: os.MkdirAll(filepath.Dir(path), 0755) out, err := os.Create(path) if err != nil { return err } _, err = io.Copy(out, tarReader) out.Close() if err != nil { return err } } } return nil } // SaveUploadedFile saves an uploaded file to a temp location. func (m *Manager) SaveUploadedFile(projectID uint, reader io.Reader, filename string) (string, error) { dir := m.ProjectDir(projectID) os.MkdirAll(dir, 0755) path := filepath.Join(dir, filename) f, err := os.Create(path) if err != nil { return "", err } defer f.Close() _, err = io.Copy(f, reader) return path, err }