Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ RUN --mount=type=cache,target="/root/.cache/go-build" EXCLUDE_PLUGIN=true EXCLUD
## Final image
FROM debian:buster-slim@sha256:5b0b1a9a54651bbe9d4d3ee96bbda2b2a1da3d2fa198ddebbced46dfdca7f216

ARG FOCALBOARD_ENVIRONMENT
ARG FOCALBOARD_ADMINS
ENV FOCALBOARD_ENVIRONMENT=${FOCALBOARD_ENVIRONMENT}
ENV FOCALBOARD_ADMINS=${FOCALBOARD_ADMINS}

RUN mkdir -p /opt/focalboard/data/files
RUN chown -R nobody:nogroup /opt/focalboard

Expand Down
159 changes: 159 additions & 0 deletions server/app/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,33 @@ func (a *App) writeArchiveBoard(zw *zip.Writer, board model.Board, opt model.Exp
}
}

// Export Users with Username Information
// Collect all unique user IDs from the board
userIDs := a.getUserIDsForBoard(board, blocks, boardMembers)

a.logger.Debug("Exporting users for board",
mlog.String("board_id", board.ID),
mlog.Int("user_count", len(userIDs)),
)
// Fetch and export user data in batch for efficiency
if len(userIDs) > 0 {
users, err := a.GetUsersList(userIDs)
if err != nil {
// Log warning but don't fail the export
a.logger.Warn("Could not fetch all users for export",
mlog.String("board_id", board.ID),
mlog.Err(err),
)
} else {
// Write user lines
for _, user := range users {
if err = a.writeArchiveUserLine(w, user); err != nil {
return fmt.Errorf("cannot write user %s to archive: %w", user.ID, err)
}
}
}
}

// write the files
for _, filename := range files {
if err := a.writeArchiveFile(zw, filename, board.ID, opt); err != nil {
Expand Down Expand Up @@ -145,6 +172,42 @@ func (a *App) writeArchiveBoardMemberLine(w io.Writer, boardMember *model.BoardM
return err
}

// writeArchiveUserLine writes a single user to the archive.
func (a *App) writeArchiveUserLine(w io.Writer, user *model.User) error {
// Create a simplified user export (without sensitive data)
userExport := map[string]interface{}{
"userId": user.ID,
"username": user.Username,
"email": user.Email,
"firstname": user.FirstName,
"lastname": user.LastName,
"nickname": user.Nickname,
}

u, err := json.Marshal(&userExport)
if err != nil {
return err
}

line := model.ArchiveLine{
Type: "user",
Data: u,
}

u, err = json.Marshal(&line)
if err != nil {
return err
}

_, err = w.Write(u)
if err != nil {
return err
}

_, err = w.Write(newline)
return err
}

// writeArchiveBlockLine writes a single block to the archive.
func (a *App) writeArchiveBlockLine(w io.Writer, block *model.Block) error {
b, err := json.Marshal(&block)
Expand Down Expand Up @@ -253,3 +316,99 @@ func extractFilename(block *model.Block) (string, error) {
}
return filename, nil
}

// getUserIDsForBoard collects all unique user IDs referenced in the board.
func (a *App) getUserIDsForBoard(board model.Board, blocks []*model.Block, boardMembers []*model.BoardMember) []string {
userIDMap := make(map[string]bool)

// Collect from board creator
if board.CreatedBy != "" {
userIDMap[board.CreatedBy] = true
}
if board.ModifiedBy != "" {
userIDMap[board.ModifiedBy] = true
}

// Collect from board members
for _, member := range boardMembers {
if member.UserID != "" {
userIDMap[member.UserID] = true
}
}

// Get board properties to identify person/multiPerson types
personPropertyIDs := make(map[string]bool)
if board.CardProperties != nil {
for _, propMap := range board.CardProperties {
propType, typeOk := propMap["type"].(string) // Extract "type" field
propID, idOk := propMap["userId"].(string) // Extract "id" field
propName, _ := propMap["name"].(string) // Extract "name" field

if typeOk && idOk && (propType == "person" || propType == "multiPerson") {
personPropertyIDs[propID] = true
a.logger.Debug("Found person property",
mlog.String("property_id", propID),
mlog.String("property_name", propName),
mlog.String("property_type", propType),
)
}
}
}

// Collect from blocks (cards, comments, etc.)
for _, block := range blocks {
// Created by
if block.CreatedBy != "" {
userIDMap[block.CreatedBy] = true
}
// Modified by
if block.ModifiedBy != "" {
userIDMap[block.ModifiedBy] = true
}

// Check card properties for person/multiPerson fields
if block.Fields != nil {
// Fields["properties"] contains card property values
if props, ok := block.Fields["properties"].(map[string]interface{}); ok {
for propID, value := range props {
// Skip if not a person property
if !personPropertyIDs[propID] {
continue
}
// Handle single person (string - user ID)
if userID, ok := value.(string); ok && len(userID) > 0 {
userIDMap[userID] = true
a.logger.Debug("Found user in person property",
mlog.String("user_id", userID),
mlog.String("property_id", propID),
)
}
// Handle multiPerson (array of user IDs)
if userIDs, ok := value.([]interface{}); ok {
for _, uid := range userIDs {
if userID, ok := uid.(string); ok && len(userID) >= 20 {
userIDMap[userID] = true
a.logger.Debug("Found user in multiPerson property",
mlog.String("user_id", userID),
mlog.String("property_id", propID),
)
}
}
}
}
}
}
}

// Convert map to slice
userIDs := make([]string, 0, len(userIDMap))
for userID := range userIDMap {
userIDs = append(userIDs, userID)
}

a.logger.Info("Collected user IDs for export",
mlog.Int("total_users", len(userIDs)),
)

return userIDs
}
3 changes: 3 additions & 0 deletions server/app/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,9 @@ func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (*mo
return nil, fmt.Errorf("invalid board Member in archive line %d: %w", lineNum, err2)
}
boardMembers = append(boardMembers, boardMember)
case "user":
// For now, just skip user blocks during import
continue
default:
return nil, model.NewErrUnsupportedArchiveLineType(lineNum, archiveLine.Type)
}
Expand Down
Loading