From bc87a4ba1a08e7f37988f048e991444a5242dac6 Mon Sep 17 00:00:00 2001 From: Adnan Maolood Date: Sun, 16 Apr 2023 19:21:03 -0400 Subject: [PATCH] Implement job visibility This implements visibility for build jobs. The visibility can be set when submitting a build, and can also be changed retroactively from a new job settings page. --- api/graph/model/job.go | 16 ++-- api/graph/schema.graphqls | 9 ++- api/graph/schema.resolvers.go | 35 +++++++-- api/loaders/middleware.go | 10 ++- .../ae3544d6450a_add_visibility_to_job.py | 41 ++++++++++ buildsrht/app.py | 2 + buildsrht/blueprints/jobs.py | 37 +++++++-- buildsrht/blueprints/settings.py | 42 +++++++++++ buildsrht/runner.py | 8 +- buildsrht/templates/job-details.html | 75 +++++++++++++++++++ buildsrht/templates/job.html | 41 ++++++++-- buildsrht/templates/settings.html | 31 ++++++++ buildsrht/templates/submit.html | 40 ++++++++++ buildsrht/types/__init__.py | 2 +- buildsrht/types/job.py | 6 ++ schema.sql | 9 ++- 16 files changed, 368 insertions(+), 36 deletions(-) create mode 100644 buildsrht/alembic/versions/ae3544d6450a_add_visibility_to_job.py create mode 100644 buildsrht/blueprints/settings.py create mode 100644 buildsrht/templates/job-details.html create mode 100644 buildsrht/templates/settings.html diff --git a/api/graph/model/job.go b/api/graph/model/job.go index aa465a1..379aaeb 100644 --- a/api/graph/model/job.go +++ b/api/graph/model/job.go @@ -15,13 +15,14 @@ import ( ) type Job struct { - ID int `json:"id"` - Created time.Time `json:"created"` - Updated time.Time `json:"updated"` - Manifest string `json:"manifest"` - Note *string `json:"note"` - Image string `json:"image"` - Runner *string `json:"runner"` + ID int `json:"id"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` + Manifest string `json:"manifest"` + Note *string `json:"note"` + Image string `json:"image"` + Runner *string `json:"runner"` + Visibility Visibility `json:"visibility"` OwnerID int JobGroupID *int @@ -75,6 +76,7 @@ func (j *Job) Fields() *database.ModelFields { {"tags", "tags", &j.RawTags}, {"status", "status", &j.RawStatus}, {"image", "image", &j.Image}, + {"visibility", "visibility", &j.Visibility}, // Always fetch: {"id", "", &j.ID}, diff --git a/api/graph/schema.graphqls b/api/graph/schema.graphqls index d596000..a5b968d 100644 --- a/api/graph/schema.graphqls +++ b/api/graph/schema.graphqls @@ -103,6 +103,12 @@ enum JobStatus { CANCELLED } +enum Visibility { + PUBLIC + UNLISTED + PRIVATE +} + type Job { id: Int! created: Time! @@ -111,6 +117,7 @@ type Job { manifest: String! note: String tags: [String!]! + visibility: Visibility! "Name of the build image" image: String! @@ -437,7 +444,7 @@ type Mutation { executed immediately if unspecified. """ submit(manifest: String!, tags: [String!] note: String, secrets: Boolean, - execute: Boolean): Job! @access(scope: JOBS, kind: RW) + execute: Boolean, visibility: Visibility): Job! @access(scope: JOBS, kind: RW) "Queues a pending job." start(jobID: Int!): Job @access(scope: JOBS, kind: RW) diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go index 4f65af3..d87562f 100644 --- a/api/graph/schema.resolvers.go +++ b/api/graph/schema.resolvers.go @@ -189,6 +189,7 @@ func (r *jobGroupResolver) Owner(ctx context.Context, obj *model.JobGroup) (mode // Jobs is the resolver for the jobs field. func (r *jobGroupResolver) Jobs(ctx context.Context, obj *model.JobGroup) ([]*model.Job, error) { + user := auth.ForContext(ctx) var jobs []*model.Job if err := database.WithTx(ctx, &sql.TxOptions{ Isolation: 0, @@ -198,7 +199,13 @@ func (r *jobGroupResolver) Jobs(ctx context.Context, obj *model.JobGroup) ([]*mo rows, err := database. Select(ctx, job). From(`job j`). - Where(`j.job_group_id = ?`, obj.ID). + Where(sq.And{ + sq.Expr(`j.job_group_id = ?`, obj.ID), + sq.Or{ + sq.Expr(`j.owner_id = ?`, user.UserID), + sq.Expr(`j.visibility = 'PUBLIC'`), + }, + }). RunWith(tx). QueryContext(ctx) if err != nil { @@ -256,7 +263,7 @@ func (r *jobGroupResolver) Triggers(ctx context.Context, obj *model.JobGroup) ([ } // Submit is the resolver for the submit field. -func (r *mutationResolver) Submit(ctx context.Context, manifest string, tags []string, note *string, secrets *bool, execute *bool) (*model.Job, error) { +func (r *mutationResolver) Submit(ctx context.Context, manifest string, tags []string, note *string, secrets *bool, execute *bool, visibility *model.Visibility) (*model.Job, error) { man, err := LoadManifest(manifest) if err != nil { return nil, err @@ -264,6 +271,11 @@ func (r *mutationResolver) Submit(ctx context.Context, manifest string, tags []s conf := config.ForContext(ctx) user := auth.ForContext(ctx) + vis := model.VisibilityUnlisted + if visibility != nil { + vis = *visibility + } + allowFree, _ := conf.Get("builds.sr.ht", "allow-free") if allowFree != "yes" { if user.UserType != "admin" && @@ -288,19 +300,19 @@ func (r *mutationResolver) Submit(ctx context.Context, manifest string, tags []s // TODO: Refactor tags into a pg array row := tx.QueryRowContext(ctx, `INSERT INTO job ( created, updated, - manifest, owner_id, secrets, note, tags, image, status + manifest, owner_id, secrets, note, tags, image, status, visibility ) VALUES ( NOW() at time zone 'utc', NOW() at time zone 'utc', - $1, $2, $3, $4, $5, $6, $7 + $1, $2, $3, $4, $5, $6, $7, $8 ) RETURNING id, created, updated, manifest, note, image, runner, owner_id, - tags, status - `, manifest, user.UserID, sec, note, tags, man.Image, status) + tags, status, visibility + `, manifest, user.UserID, sec, note, tags, man.Image, status, vis) if err := row.Scan(&job.ID, &job.Created, &job.Updated, &job.Manifest, &job.Note, &job.Image, &job.Runner, &job.OwnerID, &job.RawTags, - &job.RawStatus); err != nil { + &job.RawStatus, &job.Visibility); err != nil { return err } @@ -928,6 +940,7 @@ func (r *userResolver) Jobs(ctx context.Context, obj *model.User, cursor *coremo cursor = coremodel.NewCursor(nil) } + user := auth.ForContext(ctx) var jobs []*model.Job if err := database.WithTx(ctx, &sql.TxOptions{ Isolation: 0, @@ -937,7 +950,13 @@ func (r *userResolver) Jobs(ctx context.Context, obj *model.User, cursor *coremo query := database. Select(ctx, job). From(`job j`). - Where(`j.owner_id = ?`, obj.ID) + Where(sq.And{ + sq.Expr(`j.owner_id = ?`, obj.ID), + sq.Or{ + sq.Expr(`j.owner_id = ?`, user.UserID), + sq.Expr(`j.visibility = 'PUBLIC'`), + }, + }) jobs, cursor = job.QueryWithCursor(ctx, tx, query, cursor) return nil }); err != nil { diff --git a/api/loaders/middleware.go b/api/loaders/middleware.go index 6a17188..7575d12 100644 --- a/api/loaders/middleware.go +++ b/api/loaders/middleware.go @@ -11,6 +11,7 @@ import ( "github.com/lib/pq" "git.sr.ht/~sircmpwn/builds.sr.ht/api/graph/model" + "git.sr.ht/~sircmpwn/core-go/auth" "git.sr.ht/~sircmpwn/core-go/database" ) @@ -118,6 +119,7 @@ func fetchUsersByName(ctx context.Context) func(names []string) ([]*model.User, } func fetchJobsByID(ctx context.Context) func(ids []int) ([]*model.Job, []error) { + user := auth.ForContext(ctx) return func(ids []int) ([]*model.Job, []error) { jobs := make([]*model.Job, len(ids)) if err := database.WithTx(ctx, &sql.TxOptions{ @@ -131,7 +133,13 @@ func fetchJobsByID(ctx context.Context) func(ids []int) ([]*model.Job, []error) query := database. Select(ctx, (&model.Job{}).As("job")). From(`job`). - Where(sq.Expr(`job.id = ANY(?)`, pq.Array(ids))) + Where(sq.And{ + sq.Expr(`job.id = ANY(?)`, pq.Array(ids)), + sq.Or{ + sq.Expr(`job.owner_id = ?`, user.UserID), + sq.Expr(`job.visibility != 'PRIVATE'`), + }, + }) if rows, err = query.RunWith(tx).QueryContext(ctx); err != nil { return err } diff --git a/buildsrht/alembic/versions/ae3544d6450a_add_visibility_to_job.py b/buildsrht/alembic/versions/ae3544d6450a_add_visibility_to_job.py new file mode 100644 index 0000000..e00e202 --- /dev/null +++ b/buildsrht/alembic/versions/ae3544d6450a_add_visibility_to_job.py @@ -0,0 +1,41 @@ +"""Add visibility to job + +Revision ID: ae3544d6450a +Revises: 76bb268d91f7 +Create Date: 2023-03-13 10:33:49.830104 + +""" + +# revision identifiers, used by Alembic. +revision = 'ae3544d6450a' +down_revision = '76bb268d91f7' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.execute(""" + CREATE TYPE visibility AS ENUM ( + 'PUBLIC', + 'UNLISTED', + 'PRIVATE' + ); + + ALTER TABLE job + ADD COLUMN visibility visibility; + + UPDATE job + SET visibility = 'UNLISTED'::visibility; + + ALTER TABLE job + ALTER COLUMN visibility + SET NOT NULL; + """) + + +def downgrade(): + op.execute(""" + ALTER TABLE job DROP COLUMN visibility; + DROP TYPE visibility; + """) diff --git a/buildsrht/app.py b/buildsrht/app.py index e5321a2..0eeca8d 100644 --- a/buildsrht/app.py +++ b/buildsrht/app.py @@ -28,10 +28,12 @@ class BuildApp(SrhtFlask): from buildsrht.blueprints.api import api from buildsrht.blueprints.jobs import jobs from buildsrht.blueprints.secrets import secrets + from buildsrht.blueprints.settings import settings from srht.graphql import gql_blueprint self.register_blueprint(admin) self.register_blueprint(api) + self.register_blueprint(settings) self.register_blueprint(jobs) self.register_blueprint(secrets) self.register_blueprint(gql_blueprint) diff --git a/buildsrht/blueprints/jobs.py b/buildsrht/blueprints/jobs.py index 67c820f..12d3a62 100644 --- a/buildsrht/blueprints/jobs.py +++ b/buildsrht/blueprints/jobs.py @@ -3,7 +3,7 @@ from buildsrht.manifest import Manifest from buildsrht.rss import generate_feed from buildsrht.runner import submit_build, requires_payment from buildsrht.search import apply_search -from buildsrht.types import Job, JobStatus, Task, TaskStatus, User +from buildsrht.types import Job, JobStatus, Task, TaskStatus, User, Visibility from datetime import datetime, timedelta from flask import Blueprint, render_template, request, abort, redirect from flask import Response, url_for @@ -35,6 +35,23 @@ metrics = type("metrics", tuple(), { requests_session = requests.Session() +def get_access(job, user=None): + user = user or current_user + + # Anonymous + if not user: + if job.visibility == Visibility.PRIVATE: + return False + return True + + # Owner + if user.id == job.owner_id: + return True + + if job.visibility == Visibility.PRIVATE: + return False + return True + def tags(tags): if not tags: return list() @@ -239,6 +256,7 @@ def submit_POST(): valid.expect(not _manifest or len(_manifest) < max_len, "Manifest must be less than {} bytes".format(max_len), field="manifest") + visibility = valid.require("visibility") payment_required = requires_payment(current_user) valid.expect(not payment_required, "A paid account is required to submit new jobs") @@ -249,7 +267,8 @@ def submit_POST(): except Exception as ex: valid.error(str(ex), field="manifest") return render_template("submit.html", **valid.kwargs) - job_id = submit_build(current_user, _manifest, note=note) + job_id = submit_build(current_user, _manifest, note=note, + visibility=visibility) return redirect("/~" + current_user.username + "/job/" + str(job_id)) @jobs.route("/cancel/", methods=["POST"]) @@ -269,8 +288,8 @@ def user(username): if not user: abort(404) jobs = Job.query.filter(Job.owner_id == user.id) - if not current_user or current_user.id != user.id: - pass # TODO: access controls + if not current_user or user.id != current_user.id: + jobs = jobs.filter(Job.visibility == Visibility.PUBLIC) origin = cfg("builds.sr.ht", "origin") rss_feed = { "title": f"{user.username}'s jobs", @@ -287,8 +306,8 @@ def user_rss(username): if not user: abort(404) jobs = Job.query.filter(Job.owner_id == user.id) - if not current_user or current_user.id != user.id: - pass # TODO: access controls + if not current_user or user.id != current_user.id: + jobs = jobs.filter(Job.visibility == Visibility.PUBLIC) return jobs_feed(jobs, f"{user.username}'s jobs", "jobs.user", username=username) @@ -316,7 +335,7 @@ def tag(username, path): jobs = Job.query.filter(Job.owner_id == user.id)\ .filter(Job.tags.ilike(path + "%")) if not current_user or current_user.id != user.id: - pass # TODO: access controls + jobs = jobs.filter(Job.visibility == Visibility.PUBLIC) origin = cfg("builds.sr.ht", "origin") rss_feed = { "title": "/".join([f"~{user.username}"] + @@ -336,7 +355,7 @@ def tag_rss(username, path): jobs = Job.query.filter(Job.owner_id == user.id)\ .filter(Job.tags.ilike(path + "%")) if not current_user or current_user.id != user.id: - pass # TODO: access controls + jobs = jobs.filter(Job.visibility == Visibility.PUBLIC) base_title = "/".join([f"~{user.username}"] + [t["name"] for t in tags(path)]) return jobs_feed(jobs, base_title + " jobs", @@ -406,6 +425,8 @@ def job_by_id(username, job_id): job = Job.query.options(sa.orm.joinedload(Job.tasks)).get(job_id) if not job: abort(404) + if not get_access(job): + abort(404) logs = list() build_user = cfg("git.sr.ht::dispatch", "/usr/bin/buildsrht-keys", "builds:builds").split(":")[0] final_status = [ diff --git a/buildsrht/blueprints/settings.py b/buildsrht/blueprints/settings.py new file mode 100644 index 0000000..0368a5c --- /dev/null +++ b/buildsrht/blueprints/settings.py @@ -0,0 +1,42 @@ +from flask import Blueprint, current_app, render_template, request, url_for, abort, redirect +from flask import current_app +from srht.database import db +from srht.oauth import current_user, loginrequired +from srht.validation import Validation +from buildsrht.types import Job, Visibility + +settings = Blueprint("settings", __name__) + +@settings.route("/~/job//settings/details") +@loginrequired +def details_GET(username, job_id): + job = Job.query.get(job_id) + if not job: + abort(404) + if current_user.id != job.owner_id: + abort(404) + return render_template("job-details.html", + view="details", job=job) + +@settings.route("/~/job//settings/details", methods=["POST"]) +@loginrequired +def details_POST(username, job_id): + job = Job.query.get(job_id) + if not job: + abort(404) + if current_user.id != job.owner_id: + abort(404) + + valid = Validation(request) + visibility = valid.require("visibility") + if not valid.ok: + return render_template("job-details.html", + job=job, **valid.kwargs), 400 + + # TODO: GraphQL mutation to update job details + job.visibility = visibility + db.session.commit() + + return redirect(url_for("settings.details_GET", + username=job.owner.username, + job_id=job.id)) diff --git a/buildsrht/runner.py b/buildsrht/runner.py index b3ddfaa..0c18291 100644 --- a/buildsrht/runner.py +++ b/buildsrht/runner.py @@ -22,14 +22,14 @@ runner = Celery('builds', broker=builds_broker, config_source={ builds_queue_metrics_collector = RedisQueueCollector(builds_broker, "buildsrht_builds", "Number of builds currently in queue") builds_submitted = Counter("buildsrht_builds_submited", "Number of builds submitted") -def submit_build(user, manifest, note=None, tags=[]): +def submit_build(user, manifest, note=None, tags=[], visibility=None): resp = exec_gql("builds.sr.ht", """ - mutation SubmitBuild($manifest: String!, $tags: [String!], $note: String) { - submit(manifest: $manifest, tags: $tags, note: $note) { + mutation SubmitBuild($manifest: String!, $tags: [String!], $note: String, $visibility: Visibility) { + submit(manifest: $manifest, tags: $tags, note: $note, visibility: $visibility) { id } } - """, user=user, manifest=manifest, note=note, tags=tags) + """, user=user, manifest=manifest, note=note, tags=tags, visibility=visibility) return resp["submit"]["id"] def requires_payment(user): diff --git a/buildsrht/templates/job-details.html b/buildsrht/templates/job-details.html new file mode 100644 index 0000000..769a5a9 --- /dev/null +++ b/buildsrht/templates/job-details.html @@ -0,0 +1,75 @@ +{% extends "settings.html" %} +{% block title %} +Configure {{url_for("jobs.user", username=job.owner.username)}}/#{{job.id}} + — {{ cfg("sr.ht", "site-name") }} +{% endblock %} +{% block content %} +
+ {{csrf_token()}} +
+
+ Job Visibility +
+ +
+
+ +
+
+ +
+
+ {{ valid.summary() }} + + + +
+
+{% endblock %} diff --git a/buildsrht/templates/job.html b/buildsrht/templates/job.html index e5eed5e..1a7b9bf 100644 --- a/buildsrht/templates/job.html +++ b/buildsrht/templates/job.html @@ -13,15 +13,46 @@ {% endif %} {% endblock %} {% block body %} +
+
+

+ {{ job.owner }}/#{{ job.id }} +

+ +
+

- #{{ job.id }} - - {{icon(icon_map.get(job.status), cls=status_map.get(job.status, ""))}} - {{ job.status.value }} - + {{icon(icon_map.get(job.status), cls=status_map.get(job.status, ""))}} + {{ job.status.value }}

{% if job.note %} diff --git a/buildsrht/templates/settings.html b/buildsrht/templates/settings.html new file mode 100644 index 0000000..78041fa --- /dev/null +++ b/buildsrht/templates/settings.html @@ -0,0 +1,31 @@ +{% extends "layout.html" %} +{% block body %} +
+
+ {% macro link(path, title) %} + {{ title }} + {% endmacro %} +

+ {{ job.owner }}/#{{ job.id }} +

+ +
+
+
+ {% block content %}{% endblock %} +
+{% endblock %} diff --git a/buildsrht/templates/submit.html b/buildsrht/templates/submit.html index a01d0a6..a3d4e72 100644 --- a/buildsrht/templates/submit.html +++ b/buildsrht/templates/submit.html @@ -70,6 +70,46 @@ rows="{{note_rows}}" >{{note if note else ""}}
+
+ Visibility +
+ +
+
+ +
+
+ +
+