Help language development. Donate to The Perl Foundation

SparrowCI - super fun and flexible CI system with many programming languages support zef:melezhik last updated on 2023-01-01

app.raku
use Cro::HTTP::Router;
use Cro::HTTP::Server;
use Cro::WebApp::Template;
use SparrowCI::DB;
use SparrowCI::User;
use SparrowCI::News;
use SparrowCI::HTML;
use SparrowCI::Conf;
use SparrowCI::Security;
use SparrowCI::Repo;
use Text::Markdown;
use JSON::Fast;
use Cro::HTTP::Client;
use File::Directory::Tree;
use Digest::SHA1::Native;

my $application = route {

  my %conf = get-sparrowci-conf();

  get -> :$message, :$user is cookie, :$token is cookie, :$theme is cookie = default-theme() {
    my @results = get-builds();
    #die @results.perl;
    template 'templates/main.crotmp', %(
      page-title => "SparrowCI - super fun and flexible CI system with many programming languages support",
      message => $message,
      title => title(),   
      results => @results,
      css => css($theme),
      theme => $theme,
      navbar => navbar($user, $token, $theme),
    )
  }

  get -> 'all', :$user is cookie, :$token is cookie, :$theme is cookie = default-theme() {
    my @results = get-builds(1000);
    #die @results.perl;
    template 'templates/main.crotmp', %( 
      page-title => "All Reports",
      title => title(),   
      results => @results,
      css => css($theme),
      theme => $theme,
      navbar => navbar($user, $token, $theme),
    )
  }

  get -> Str $user-id, 'builds', :$user is cookie, :$token is cookie, :$theme is cookie = default-theme() {
    my @results = get-builds(100,$user-id);
    #die @results.perl;
    template 'templates/main.crotmp', %( 
      page-title => "{$user-id}'s Reports",
      title => title(),   
      results => @results,
      css => css($theme),
      theme => $theme,
      navbar => navbar($user, $token, $theme),
    )
  }

  get -> 'report', Int $id, :$user is cookie, :$token is cookie, :$theme is cookie = default-theme() {

    my %report = get-report($id);

    my $path =  %report<with-sparrowci>:exists ?? 'templates/report2.crotmp' !! 'templates/report.crotmp';
    my $title = %report<with-sparrowci>:exists ?? "SparrowCI Report - {%report<project>} | [image: {%report<image> || 'NA'}]" !! "SparrowCI Report - {%report<project>}";

    if %report<project> ~~ /"gh-" (\S+?) "-" (\S+)/ {
        %report<runner> = "$0";
        %report<repo> = "$1"; 
        %report<repo-type> = "gh";
    } elsif %report<project> ~~ /"git-" (\S+?) "-" (\S+)/ {
        %report<runner> = "$0";
        %report<repo> = "$1"; 
        %report<repo-type> = "git";
    }

    %report<user> = $user || "";

    template $path, %( 
      page-title => $title,
      title => title(),   
      %report,
      css => css($theme),
      theme => $theme,
      navbar => navbar($user, $token, $theme),
    )
  }

  get -> 'quickstart', :$user is cookie, :$token is cookie, :$theme is cookie = default-theme() {
    template 'templates/quickstart.crotmp', %(
      page-title => "Quick Start", 
      title => title(),   
      data => parse-markdown("docs/quickstart.md".IO.slurp).to_html,
      css => css($theme),
      theme => $theme,
      navbar => navbar($user, $token, $theme),
    )
  }

  get -> 'donations', :$user is cookie, :$token is cookie, :$theme is cookie = default-theme() {
    template 'templates/donations.crotmp', %(
      page-title => "Support SparrowCI", 
      title => title(),   
      css => css($theme),
      theme => $theme,
      navbar => navbar($user, $token, $theme),
    )
  }

  get -> 'news', :$user is cookie, :$token is cookie, :$theme is cookie = default-theme() {
    my @results = get-news();
    #die @results.perl;
    template 'templates/news.crotmp', %( 
      page-title => "News",
      title => title(),   
      results => @results,
      css => css($theme),
      theme => $theme,
      navbar => navbar($user, $token, $theme),
    )
  }

  get -> 'repos', :$message, :$user is cookie, :$token is cookie, :$theme is cookie = default-theme() {
    if check-user($user, $token) == True {
      my $data = conf-login-type() eq "GH" ?? gh-repos($user) !! [];
      my @projects = projects($user);
      my $repos =  $data<>.map({ ("\"{$_<name>||''}\"") }).join(",");
      template 'templates/repos.crotmp', %(
        page-title => "Repositories", 
        title => title(),
        projects => @projects, 
        gh-repos-js => $repos,
        login-type => conf-login-type(),
        css => css($theme),
        theme => $theme,
        repos-sync-date => repos-sync-date($user),
        navbar => navbar($user, $token, $theme),
        message => $message,
      )
    } else {
      redirect :see-other, "{http-root()}/login-page?message=you need to sign in to manage repositories";
    }  
  }

  post -> 'repo', :$user is cookie, :$token is cookie, :$theme is cookie = default-theme() {
    if check-user($user, $token) == True {
      my $repo; my $type;
      request-body -> (:$repos,:$typegit) {
        $repo = $repos.subst(/\s+/,"",:g);
        $type = $typegit ?? "git" !! "gh";
        say "add repo: $repo type: $type";
      }
      my $url = $type eq "git" ?? $repo !! "https://github.com/{$user}/{$repo}.git";
      my $yaml = qq:to/YAML/;
        sparrowdo:
          no_sudo: true
          no_index_update: false
          bootstrap: false
          format: default
          repo: https://sparrowhub.io/repo
          tags: cpu=2,mem=6,SCM_URL=$url,owner=$user
        disabled: false
        keep_builds: 100
        allow_manual_run: true
        scm:
          url: $url
          branch: HEAD
      YAML
      say "yaml: $yaml";

      my $repo-dir = $type eq "git" ?? 
        "{%*ENV<HOME>}/.sparky/projects/git-{$user}-{$repo.split('/').tail}" !!
        "{%*ENV<HOME>}/.sparky/projects/gh-{$user}-$repo";

      say "create repo dir: $repo-dir";

      mkdir $repo-dir;

      "{$repo-dir}/sparky.yaml".IO.spurt($yaml);

      if "{$repo-dir}/sparrowfile".IO ~~ :e {
        say "{$repo-dir}/sparrowfile symlink exists"; 
      } else {
        say "create {$repo-dir}/sparrowfile symlink"; 
        symlink("ci.raku","{$repo-dir}/sparrowfile");
        redirect :see-other, "{http-root()}/repos?message=repo {$repo} added";
      }
    } else {
        redirect :see-other, "{http-root()}/login-page?message=you need to sign in to manage repositories"; 
    }
  }

  post -> 'repos-sync', :$user is cookie, :$token is cookie, :$theme is cookie = default-theme() {
    if check-user($user, $token) == True {
      my $repo;
      say "sync repos information from GH account for user: $user";
      sync-repos($user);  
      redirect :see-other, "{http-root()}/repos?message=repositories synced from GH account";
    } else {
      redirect :see-other, "{http-root()}/login-page?message=you need to sign in to manage repositories";
    }  
  }

  post -> 'repo-build', :$user is cookie, :$token is cookie, :$theme is cookie = default-theme() {
    if check-user($user, $token) == True {
      my $repo-id; my $repo-type;
      request-body -> (:$repo,:$type) {
        $repo-id = $repo;
        $repo-type = $type;
        say "build repo: $repo type: $type";
      }
      
      my $repo-dir = "{%*ENV<HOME>}/.sparky/projects/{$repo-type}-{$user}-{$repo-id}";

      my $id = "{('a' .. 'z').pick(20).join('')}.{$*PID}";

      my %trigger = %(
        description =>  "triggered by SparrowCI user",
      );

      mkdir "{$repo-dir}/.triggers";

      "{$repo-dir}/.triggers/$id".IO.spurt(%trigger.perl);

      say "queue repo: {$repo-id} type: {$repo-type} to build: {$repo-dir}/.triggers/$id";

      redirect :see-other, "{http-root()}/repos?message=repo {$repo-id} queued to build";

    } else {
      redirect :see-other, "{http-root()}/login-page?message=you need to sign in to manage repositories";
    }  
  }

  post -> 'build' {

    my $bid;

    request-body  -> %json {

      my $build = %json;

      my $project = %json<project>;
      my $desc = %json<desc>;
      my $state = %json<state>;
      my $job-id = %json<job-id>;
      my $image = %json<image>;

      say "generate SparrowCI build ...";

      $bid = insert-build :$state, :$image, :$project, :$desc, :$job-id;

      say "bid: $bid";

      mkdir "{sparrowci-root()}/data/{$bid}";

      $build<id> = $bid;

      "{sparrowci-root()}/data/{$bid}/data.json".IO.spurt(to-json($build));

    }

    content 'application/json', %( build-id => $bid );

  } 

  post -> 'repo-rm', :$user is cookie, :$token is cookie, :$theme is cookie = default-theme() {
    if check-user($user, $token) == True {
      my $repo-id; my $repo-type;
      request-body -> (:$repo,:$type) {
        $repo-id = $repo;
        $repo-type = $type;
        say "remove repo: $repo type: $type";
      }
      
      my $repo-dir = "{%*ENV<HOME>}/.sparky/projects/{$repo-type}-{$user}-{$repo-id}";

      if "{$repo-dir}".IO ~~ :d {
          say "remove {$repo-dir}";
        rmtree $repo-dir;
      } else {
          say "{$repo-dir} does not exist"; 
      }
      redirect :see-other, "{http-root()}/repos?message=repo {$repo-id} removed";
    } else {
      redirect :see-other, "{http-root()}/login-page?message=you need to sign in to manage repositories";
    }  
  }

  get -> 'repo', 'edit', Str $type, Str $repo-id, :$message, :$user is cookie, :$token is cookie, :$theme is cookie = default-theme() {
    if check-user($user, $token) == True {
      my %repo = get-repo($user, $repo-id, $type);
      template 'templates/repos-edit.crotmp', %(
        page-title => "Edit Repo - {$repo-id}", 
        title => title(),
        %repo, 
        css => css($theme),
        theme => $theme,
        navbar => navbar($user, $token, $theme),
        message => $message,
      )
    } else {
      redirect :see-other, "{http-root()}/login-page?message=you need to sign in to manage repositories";
    }  
  }

  get -> 'repo', Str $repo-id, 'link', :$type, :$user is cookie, :$token is cookie, :$theme is cookie = default-theme() {
    my $repo = get-repo($user,$repo-id,$type);
    if $repo && $repo<scm> && $repo<scm><url> {
      redirect :see-other, $repo<scm><url>;
    } else {
      redirect :see-other, "{http-root()}/repos?message=http link not found";
    }  
  }

  get -> 'tc', Int $id, :$user is cookie, :$token is cookie, :$theme is cookie = default-theme() {
    my %report = get-report($id);
    template 'templates/tc.crotmp', %( 
      title => title(),   
      %report,
      css => css($theme),
      theme => $theme,
      navbar => navbar($user, $token, $theme),
    )
  }

  get -> 'project', Str $project, 'badge', 'markdown', :$user is cookie, :$token is cookie, :$theme is cookie = default-theme() {

    template 'templates/badge.crotmp', %( 
      page-title => "{$project} badge",  
      title => title(),   
      badge => "[![SparrowCI](https://ci.sparrowhub.io/project/{$project}/badge)](https://ci.sparrowhub.io)",
      css => css($theme),
      theme => $theme,
      navbar => navbar($user, $token, $theme),
    )

  }

  get -> 'project', Str $project, 'badge', {
    my $b = get-last-build($project);
    cache-control :no-store, :no-cache;
    if $b<state> eq "OK" {
      redirect :see-other, 'https://img.shields.io/static/v1?label=SparrowCI&message=Build+|+OK&color=green'
    } elsif $b<state> eq "FAIL" {
      redirect :see-other, 'https://img.shields.io/static/v1?label=SparrowCI&message=Build+|+FAIL&color=red'
    } elsif $b<state> eq "TIMEOUT" {
      redirect :see-other, 'https://img.shields.io/static/v1?label=SparrowCI&message=Build+|+TIMEOUT&color=yellow'
    } else {
      redirect :see-other, 'https://img.shields.io/static/v1?label=SparrowCI&message=Build+|+UNKOWN&color=gray'
    }
  }

  get -> 'secrets', :$message, :$user is cookie, :$token is cookie, :$theme is cookie = default-theme() {
    if check-user($user, $token) == True {
      if conf-use-secrets() {
        my @secrets = secrets($user);
        template 'templates/secrets.crotmp', %(
          page-title => "Secrets", 
          title => title(),
          secrets => @secrets, 
          css => css($theme),
          theme => $theme,
          navbar => navbar($user, $token, $theme),
          message => $message,
        )
      } else {
        redirect :see-other, "{http-root()}/?message=secrets are not enabled on this instance";         
      }
    } else {
      redirect :see-other, "{http-root()}/login-page?message=you need to sign in to manage secrets";
    }  
  }

  post -> 'secret', :$user is cookie, :$token is cookie, :$theme is cookie = default-theme() {
    if check-user($user, $token) == True {
      my $secret_param; my $secret_value_param;
      request-body -> (:$secret,:$secret_value) {
        $secret_param = $secret;
        $secret_value_param = $secret_value;
        say "add secret: $secret";
      }
    $secret_param.=subst(/\s/,"",:g);  
    if $secret_param ~~ /^^ <[ a .. z A .. Z 0 .. 9 \- _ ]>+ $$/ {
      secret-add($user,$secret_param,$secret_value_param);
      redirect :see-other, "{http-root()}/secrets?message=secret {$secret_param} added";
    }  else {
      redirect :see-other, "{http-root()}/secrets?message=bad secret name";
    }
    } else {
        redirect :see-other, "{http-root()}/login-page?message=you need to sign in to manage secrets"; 
    }
  }

  post -> 'rm-secret', :$user is cookie, :$token is cookie, :$theme is cookie = default-theme() {
    if check-user($user, $token) == True {
      my $secret_param;
      request-body -> (:$secret) {
        $secret_param = $secret;
        say "delete secret: $secret";
      }
      secret-delete($user,$secret_param);
      redirect :see-other, "{http-root()}/secrets?message=secret {$secret_param} deleted";
    } else {
        redirect :see-other, "{http-root()}/login-page?message=you need to sign in to manage secrets"; 
    }
  }

  get -> 'account', :$message, :$user is cookie, :$token is cookie, :$theme is cookie = default-theme() {
    if check-user($user, $token) == True {
      template 'templates/account.crotmp', %(
        page-title => "Account Manager", 
        title => title(),
        login => $user, 
        login-type => conf-login-type(),
        css => css($theme),
        theme => $theme,
        navbar => navbar($user, $token, $theme),
        message => $message,
      )
    } else {
      if conf-login-type() eq "GH" {
        redirect :see-other, "{http-root()}/login-page?message=you need to sign in to manage account";
      } else {
        redirect :see-other, "{http-root()}/login-page2?message=you need to sign in to manage account";
      }
    }  
  }

  get -> 'js', *@path {
      cache-control :public, :max-age(300);
      static 'js', @path;
  }

  get -> 'css', *@path {
      cache-control :public, :max-age(300);
      static 'css', @path;
  }

  get -> 'icons', *@path {

    cache-control :public, :max-age(3000);

    static 'icons', @path;

  }

  get -> 'set-theme', :$message, :$theme, :$user is cookie, :$token is cookie {

    my $date = DateTime.now.later(years => 100);

    set-cookie 'theme', $theme, http-only => True, expires => $date;

    redirect :see-other, "{http-root()}/?message=theme set to {$theme}";

  }

  #
  # Authentication methods
  #
  
  get -> 'login' {
    redirect :see-other,
      "https://github.com/login/oauth/authorize?client_id={%*ENV<OAUTH_CLIENT_ID>}&state={%*ENV<OAUTH_STATE>}"
  }

  post -> 'logout', :$user is cookie, :$token is cookie {

    set-cookie 'user', Nil;
    set-cookie 'token', Nil;

    if ( $user && $token && "{cache-root()}/users/{$user}/tokens/{$token}".IO ~~ :e ) {

      unlink "{cache-root()}/users/{$user}/tokens/{$token}";
      say "unlink user token - {cache-root()}/users/{$user}/tokens/{$token}";

      if ( $user && $token && "{cache-root()}/users/{$user}/meta.json".IO ~~ :e ) {

        unlink "{cache-root()}/users/{$user}/meta.json";
        say "unlink user meta - {cache-root()}/users/{$user}/meta.json";

      }

    }
    redirect :see-other, "{http-root()}/?message=user logged out";
  } 

  post -> 'chgpass', :$user is cookie, :$token is cookie, :$theme is cookie = default-theme() {
    if check-user($user, $token) == True {
      my $password_param;
      request-body -> (:$password) {
        $password_param = $password;
        say "change password user: $user";
      }
      if get-user($user) {
        update-user(:login($user), :password($password_param));
      } else {
        insert-user(:login($user), :password($password_param),:description<user>)
      }
      redirect :see-other, "{http-root()}/account?message=password changed";
    } else {
        redirect :see-other, "{http-root()}/login-page?message=you need to sign in to change password"; 
    }
  }

  get -> 'oauth2', :$state, :$code {

      say "request token from https://github.com/login/oauth/access_token";

      my $resp = await Cro::HTTP::Client.get: 'https://github.com/login/oauth/access_token',
        headers => [
          "Accept" => "application/json"
        ],
        query => { 
          redirect_uri => "https://ci.sparrowhub.io/oauth2",
          client_id => %*ENV<OAUTH_CLIENT_ID>,
          client_secret => %*ENV<OAUTH_CLIENT_SECRET>,
          code => $code,
          state => $state,    
        };


      my $data = await $resp.body-text();

      my %data = from-json($data);

      say "response recieved - {%data.perl} ... ";

      if %data<access_token>:exists {

        say "token recieved - {%data<access_token>} ... ";

        my $resp = await Cro::HTTP::Client.get: 'https://api.github.com/user',
          headers => [
            "Accept" => "application/vnd.github.v3+json",
            "Authorization" => "token {%data<access_token>}"
          ];

        my $data2 = await $resp.body-text();
  
        my %data2 = from-json($data2);

        %data2<access_token> = %data<access_token>;

        say "set user login to {%data2<login>}";

        my $date = DateTime.now.later(years => 100);

        set-cookie 'user', %data2<login>, http-only => True, expires => $date;

        set-cookie 'token', user-create-account(%data2<login>,%data2), http-only => True, expires => $date;

        redirect :see-other, "{http-root()}/?message=user logged in";

      } else {

        redirect :see-other, "{http-root()}/?message=issues with login";

      }
      
  }

  get -> 'login-page', :$message, :$user is cookie, :$token is cookie, :$theme is cookie = default-theme() {

    template 'templates/login-page.crotmp', {
      page-title => "Login page",
      title => title(),
      http-root => http-root(),
      message => $message || "sign in using your github account",
      css => css($theme),
      theme => $theme,
      navbar => navbar($user, $token, $theme),
    }
  }

  get -> 'login-page2', :$message, :$user is cookie, :$token is cookie, :$theme is cookie = default-theme() {

    template 'templates/login-page2.crotmp', {
      page-title => "Login page",
      title => title(),
      http-root => http-root(),
      message => $message || "sign in using your credentials",
      css => css($theme),
      theme => $theme,
      navbar => navbar($user, $token, $theme),
    }
  }

  post -> 'login' {
    my $user; my $password_param; my $create_param;
    request-body -> (:$login,:$password,:$create) {
      $user = $login;
      $password_param = $password;
      $create_param = $create;
      my $masked_password = $password ?? "*" x $password.chars !! "";
      say "login user: $user, create: {$create || 'off'}, password: {$masked_password}";
    }

    my $user-acc = get-user($user);
    my $password_param_enc;
    if $user-acc {
      say "account exists";
      $password_param_enc = sha1-hex("{$user-acc<salt>}{$password_param}")
    } else {
      say "account does not exist"
    }

    if !$user-acc and is-admin-login($user) and $password_param eq conf-admin-password()  {

      say "(1) login user: $user - OK";

      say "set user login to {$user}";

      set-cookie 'user', $user;

      set-cookie 'token', user-create-account($user);

      redirect :see-other, "{http-root()}/?message=user successfully logged in";

    } elsif $user-acc and $user-acc<password> eq $password_param_enc {

      say "(2) login user: $user - OK";

      say "set user login to {$user}";

      set-cookie 'user', $user;

      set-cookie 'token', user-create-account($user);

      redirect :see-other, "{http-root()}/?message=user successfully logged in";

    } elsif $user-acc and $user-acc<password> ne $password_param {

        say "(3) login user: $user - FAIL";
        redirect :see-other, "{http-root()}/login-page2?message=bad credentials"; 

    } elsif !$user-acc and $create_param and $password_param {

      say "create user: $user ...";

      if $user ~~ /^^ <[ a .. z A .. Z 0 .. 9 _ ]>+ $$/ {

        insert-user(:login($user), :password($password_param),:description<user>);

        say "(4) login user: $user - OK";

        say "set user login to {$user}";

        set-cookie 'user', $user;

        set-cookie 'token', user-create-account($user);

        redirect :see-other, "{http-root()}/?message=user successfully created and logged in";

      } else {
        redirect :see-other, "{http-root()}/login-page2?message=bad login";
      }  

    }  else {

        say "(5) login user: $user - FAIL";

        redirect :see-other, "{http-root()}/login-page2?message=bad credentials"; 
    }
  }

}

(.out-buffer = False for $*OUT, $*ERR);

my Cro::Service $service = Cro::HTTP::Server.new:
    :host<127.0.0.1>, :port<2222>, :$application;

$service.start;

react whenever signal(SIGINT) {
    $service.stop;
    exit;
}