package app import ( "context" "database/sql" "errors" "fmt" "strings" ) func (a *App) listProjects(ctx context.Context, userID string) ([]Project, error) { rows, err := a.db.QueryContext(ctx, `SELECT p.id, p.user_id, p.name, p.slug, p.description, p.repo_url, p.repo_private, p.service_name, p.route_host, p.workspace_id, w.name, w.unix_user, p.deploy_token, p.provision_state, p.provision_error, p.last_deployed_at, p.last_deployed_commit, p.db_name, p.db_user, p.db_password, p.created_at FROM projects p JOIN workspaces w ON w.id = p.workspace_id WHERE p.user_id=$1 ORDER BY p.created_at DESC`, userID) if err != nil { return nil, err } defer rows.Close() var out []Project for rows.Next() { var p Project var deployedAt sql.NullTime if err := rows.Scan(&p.ID, &p.UserID, &p.Name, &p.Slug, &p.Description, &p.RepoURL, &p.RepoPrivate, &p.ServiceName, &p.RouteHost, &p.WorkspaceID, &p.Workspace, &p.UnixUser, &p.DeployToken, &p.ProvisionState, &p.ProvisionError, &deployedAt, &p.LastDeployedCommit, &p.DBName, &p.DBUser, &p.DBPassword, &p.CreatedAt); err != nil { return nil, err } if deployedAt.Valid { t := deployedAt.Time.UTC() p.LastDeployedAt = &t } p.WebhookURL = a.deployWebhookURL(p.ID, p.DeployToken) p.AppURL = a.appURL(p.RouteHost) out = append(out, p) } return out, rows.Err() } func (a *App) createProject(ctx context.Context, user User, workspaceID int64, name, description string, private bool, existingRepoURL string) (Project, error) { slug := slugify(name) if slug == "" { return Project{}, errors.New("project name must include letters or numbers") } workspace, err := a.getWorkspace(ctx, user.Username, workspaceID) if err != nil { return Project{}, err } repoURL := existingRepoURL serviceName := fmt.Sprintf("projectmgr-%s-%s.service", slugify(user.Username), slug) routeHost := a.buildRouteHost(slug, user.Username) deployToken, err := newDeployToken() if err != nil { return Project{}, err } var p Project tx, err := a.db.BeginTx(ctx, nil) if err != nil { return Project{}, err } defer tx.Rollback() var createdDeployedAt sql.NullTime err = tx.QueryRowContext(ctx, `INSERT INTO projects (user_id, workspace_id, name, slug, description, repo_url, repo_private, service_name, route_host, deploy_token, provision_state, provision_error) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'pending','') RETURNING id, user_id, name, slug, description, repo_url, repo_private, service_name, route_host, workspace_id, deploy_token, provision_state, provision_error, last_deployed_at, last_deployed_commit, db_name, db_user, db_password, created_at`, user.Username, workspaceID, name, slug, description, repoURL, private, serviceName, routeHost, deployToken, ).Scan(&p.ID, &p.UserID, &p.Name, &p.Slug, &p.Description, &p.RepoURL, &p.RepoPrivate, &p.ServiceName, &p.RouteHost, &p.WorkspaceID, &p.DeployToken, &p.ProvisionState, &p.ProvisionError, &createdDeployedAt, &p.LastDeployedCommit, &p.DBName, &p.DBUser, &p.DBPassword, &p.CreatedAt) if err != nil { if strings.Contains(err.Error(), "duplicate key") { return Project{}, errors.New("project with this name already exists") } return Project{}, err } if createdDeployedAt.Valid { t := createdDeployedAt.Time.UTC() p.LastDeployedAt = &t } if err := tx.Commit(); err != nil { return Project{}, err } p.Workspace = workspace.Name p.UnixUser = workspace.UnixUser p.WebhookURL = a.deployWebhookURL(p.ID, p.DeployToken) p.AppURL = a.appURL(p.RouteHost) return p, a.provisionProject(ctx, &p) } func (a *App) provisionProject(ctx context.Context, p *Project) error { if err := a.ensureProjectDatabase(ctx, p); err != nil { _ = a.updateProvisioningState(ctx, p.ID, "failed", err.Error()) p.ProvisionState = "failed" p.ProvisionError = err.Error() return err } if p.RepoURL == "" { if a.cfg.ForgejoBaseURL == "" || a.cfg.ForgejoToken == "" { err := errors.New("repo_url is empty and Forgejo is not configured; set FORGEJO_BASE_URL/FORGEJO_TOKEN or provide repo_url") _ = a.updateProvisioningState(ctx, p.ID, "failed", err.Error()) p.ProvisionState = "failed" p.ProvisionError = err.Error() return err } repoURL, err := a.createForgejoRepo(ctx, p.Slug, p.Description, p.RepoPrivate) if err != nil { _ = a.updateProvisioningState(ctx, p.ID, "failed", err.Error()) p.ProvisionState = "failed" p.ProvisionError = err.Error() return err } p.RepoURL = repoURL if _, err := a.db.ExecContext(ctx, `UPDATE projects SET repo_url=$1 WHERE id=$2`, repoURL, p.ID); err != nil { _ = a.updateProvisioningState(ctx, p.ID, "failed", err.Error()) p.ProvisionState = "failed" p.ProvisionError = err.Error() return err } } if err := a.ensureForgejoDeployWebhook(ctx, *p); err != nil { _ = a.updateProvisioningState(ctx, p.ID, "failed", err.Error()) p.ProvisionState = "failed" p.ProvisionError = err.Error() return err } if p.RepoURL != "" { if err := a.buildProjectBinary(*p); err != nil { _ = a.updateProvisioningState(ctx, p.ID, "failed", err.Error()) p.ProvisionState = "failed" p.ProvisionError = err.Error() return err } } if err := a.renderSystemdForProject(ctx, *p); err != nil { _ = a.updateProvisioningState(ctx, p.ID, "failed", err.Error()) p.ProvisionState = "failed" p.ProvisionError = err.Error() return err } if err := a.ensureCaddyRoute(ctx, *p); err != nil { _ = a.updateProvisioningState(ctx, p.ID, "failed", err.Error()) p.ProvisionState = "failed" p.ProvisionError = err.Error() return err } if err := a.updateProvisioningState(ctx, p.ID, "provisioned", ""); err != nil { p.ProvisionState = "failed" p.ProvisionError = err.Error() return err } p.ProvisionState = "provisioned" p.ProvisionError = "" return nil } func (a *App) getProject(ctx context.Context, userID string, projectID int64) (Project, error) { var p Project var deployedAt sql.NullTime err := a.db.QueryRowContext(ctx, `SELECT p.id, p.user_id, p.name, p.slug, p.description, p.repo_url, p.repo_private, p.service_name, p.route_host, p.workspace_id, w.name, w.unix_user, p.deploy_token, p.provision_state, p.provision_error, p.last_deployed_at, p.last_deployed_commit, p.db_name, p.db_user, p.db_password, p.created_at FROM projects p JOIN workspaces w ON w.id=p.workspace_id WHERE p.user_id=$1 AND p.id=$2`, userID, projectID). Scan(&p.ID, &p.UserID, &p.Name, &p.Slug, &p.Description, &p.RepoURL, &p.RepoPrivate, &p.ServiceName, &p.RouteHost, &p.WorkspaceID, &p.Workspace, &p.UnixUser, &p.DeployToken, &p.ProvisionState, &p.ProvisionError, &deployedAt, &p.LastDeployedCommit, &p.DBName, &p.DBUser, &p.DBPassword, &p.CreatedAt) if err != nil { if errors.Is(err, sql.ErrNoRows) { return Project{}, errors.New("project not found") } return Project{}, err } if deployedAt.Valid { t := deployedAt.Time.UTC() p.LastDeployedAt = &t } p.WebhookURL = a.deployWebhookURL(p.ID, p.DeployToken) p.AppURL = a.appURL(p.RouteHost) return p, nil } func (a *App) updateProvisioningState(ctx context.Context, projectID int64, state, errText string) error { _, err := a.db.ExecContext(ctx, `UPDATE projects SET provision_state=$1, provision_error=$2 WHERE id=$3`, state, errText, projectID) return err } func (a *App) reprovisionProject(ctx context.Context, userID string, projectID int64) (Project, error) { p, err := a.getProject(ctx, userID, projectID) if err != nil { return Project{}, err } if err := a.updateProvisioningState(ctx, p.ID, "pending", ""); err != nil { return Project{}, err } p.ProvisionState = "pending" p.ProvisionError = "" if err := a.provisionProject(ctx, &p); err != nil { return p, err } return p, nil } func (a *App) listEnvVars(ctx context.Context, userID string, projectID int64) ([]EnvVar, error) { if _, err := a.getProject(ctx, userID, projectID); err != nil { return nil, err } rows, err := a.db.QueryContext(ctx, `SELECT id, project_id, key, value FROM project_env_vars WHERE project_id=$1 ORDER BY key`, projectID) if err != nil { return nil, err } defer rows.Close() var out []EnvVar for rows.Next() { var v EnvVar if err := rows.Scan(&v.ID, &v.ProjectID, &v.Key, &v.Value); err != nil { return nil, err } out = append(out, v) } return out, rows.Err() } func (a *App) upsertEnvVar(ctx context.Context, userID string, projectID int64, key, value string) error { p, err := a.getProject(ctx, userID, projectID) if err != nil { return err } _, err = a.db.ExecContext(ctx, `INSERT INTO project_env_vars (project_id, key, value) VALUES ($1,$2,$3) ON CONFLICT(project_id, key) DO UPDATE SET value = EXCLUDED.value`, projectID, key, value) if err != nil { return err } return a.renderSystemdForProject(ctx, p) } func (a *App) deleteEnvVar(ctx context.Context, userID string, projectID int64, key string) error { p, err := a.getProject(ctx, userID, projectID) if err != nil { return err } _, err = a.db.ExecContext(ctx, `DELETE FROM project_env_vars WHERE project_id=$1 AND key=$2`, projectID, key) if err != nil { return err } return a.renderSystemdForProject(ctx, p) }