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.
---
Now that we have a settings page, we could also add a way to delete
jobs.
api/graph/model/job.go | 16 ++--
api/graph/schema.graphqls | 9 ++-
api/graph/schema.resolvers.go | 30 ++++++--
api/loaders/middleware.go | 10 ++-
.../ae3544d6450a_add_visibility_to_job.py | 41 ++++++++++
buildsrht/app.py | 2 +
buildsrht/blueprints/jobs.py | 33 ++++++--
buildsrht/blueprints/settings.py | 42 +++++++++++
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 ++-
15 files changed, 356 insertions(+), 31 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..c327770 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! = UNLISTED): 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..91c5910 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
@@ -288,19 +295,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, visibility)
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 +935,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 +945,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 d36a052..6a83822 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
@@ -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
@@ -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..173001a 100644
--- a/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()
@@ -269,8 +286,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 +304,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 +333,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 +353,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 +423,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
@@ -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("/~<username>/job/<int:job_id>/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("/~<username>/job/<int:job_id>/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/templates/job-details.html b/buildsrht/templates/job-details.html
new file mode 100644
index 0000000..769a5a9
--- /dev/null
@@ -0,0 +1,75 @@
+{% extends "settings.html" %}
+{% block title %}
+<title>Configure {{url_for("jobs.user", username=job.owner.username)}}/#{{job.id}}
+ — {{ cfg("sr.ht", "site-name") }}</title>
+{% endblock %}
+{% block content %}
+<form class="row" method="POST">
+ {{csrf_token()}}
+ <div class="col-md-6 d-flex flex-column">
+ <fieldset class="form-group">
+ <legend>Job Visibility</legend>
+ <div class="form-check">
+ <label class="form-check-label">
+ <input
+ class="form-check-input"
+ type="radio"
+ name="visibility"
+ value="PUBLIC"
+ {% if job.visibility.value == "PUBLIC" %}
+ checked
+ {% endif %}
+ > Public
+ <small id="visibility-public-help" class="form-text text-muted">
+ Shown on your profile page
+ </small>
+ </label>
+ </div>
+ <div class="form-check">
+ <label
+ class="form-check-label"
+ title="Visible to anyone with the link, but not shown on your profile"
+ >
+ <input
+ class="form-check-input"
+ type="radio"
+ name="visibility"
+ value="UNLISTED"
+ {% if job.visibility.value == "UNLISTED" %}
+ checked
+ {% endif %}
+ > Unlisted
+ <small id="visibility-unlisted-help" class="form-text text-muted">
+ Visible to anyone who knows the URL, but not shown on your profile
+ </small>
+ </label>
+ </div>
+ <div class="form-check">
+ <label
+ class="form-check-label"
+ title="Only visible to you and your collaborators"
+ >
+ <input
+ class="form-check-input"
+ type="radio"
+ name="visibility"
+ value="PRIVATE"
+ {% if job.visibility.value == "PRIVATE" %}
+ checked
+ {% endif %}
+ > Private
+ <small id="visibility-unlisted-help" class="form-text text-muted">
+ Only visible to you and your collaborators
+ </small>
+ </label>
+ </div>
+ </fieldset>
+ {{ valid.summary() }}
+ <span class="pull-right">
+ <button type="submit" class="btn btn-primary">
+ Save {{icon("caret-right")}}
+ </button>
+ </span>
+ </div>
+</form>
+{% endblock %}
diff --git a/buildsrht/templates/job.html b/buildsrht/templates/job.html
index e5eed5e..1a7b9bf 100644
--- a/buildsrht/templates/job.html
@@ -13,15 +13,46 @@
{% endif %}
{% endblock %}
{% block body %}
+<div class="header-tabbed">
+ <div class="container-fluid">
+ <h2>
+ <a href="{{ url_for("jobs.user", username=job.owner.username) }}">{{ job.owner }}</a>/<wbr
+ >#{{ job.id }}
+ </h2>
+ <ul class="nav nav-tabs">
+ {% if job.visibility.value != "PUBLIC" %}
+ <li
+ class="nav-item nav-text vis-{{job.visibility.value.lower()}}"
+ {% if job.visibility.value == "UNLISTED" %}
+ title="This job is only visible to those who know the URL."
+ {% elif job.visibility.value == "PRIVATE" %}
+ title="This job is only visible to those who were invited to view it."
+ {% endif %}
+ >
+ {% if job.visibility.value == "UNLISTED" %}
+ Unlisted
+ {% elif job.visibility.value == "PRIVATE" %}
+ Private
+ {% endif %}
+ </li>
+ {% endif %}
+ {% if current_user and current_user.id == job.owner_id %}
+ <li class="nav-item">
+ <a class="nav-link" href="{{url_for("settings.details_GET",
+ username=job.owner.username,
+ job_id=job.id)}}"
+ >settings</a>
+ </li>
+ {% endif %}
+ </ul>
+ </div>
+</div>
<div class="container-fluid">
<section class="row">
<div class="col-lg-3 col-md-12">
<h2>
- #{{ job.id }}
- <span class="pull-right">
- {{icon(icon_map.get(job.status), cls=status_map.get(job.status, ""))}}
- {{ job.status.value }}
- </span>
+ {{icon(icon_map.get(job.status), cls=status_map.get(job.status, ""))}}
+ {{ job.status.value }}
</h2>
<dl>
{% if job.note %}
diff --git a/buildsrht/templates/settings.html b/buildsrht/templates/settings.html
new file mode 100644
index 0000000..78041fa
--- /dev/null
@@ -0,0 +1,31 @@
+{% extends "layout.html" %}
+{% block body %}
+<div class="header-tabbed">
+ <div class="container">
+ {% macro link(path, title) %}
+ <a
+ class="nav-link {% if view == title %}active{% endif %}"
+ href="{{ path }}">{{ title }}</a>
+ {% endmacro %}
+ <h2>
+ <a href="{{ url_for("jobs.user", username=job.owner.username) }}">{{ job.owner }}</a>/<wbr
+ >#{{ job.id }}
+ </h2>
+ <ul class="nav nav-tabs">
+ <li class="nav-item">
+ <a class="nav-link"
+ href="{{ url_for("jobs.job_by_id", username=job.owner.username, job_id=job.id) }}"
+ >{{icon("caret-left")}} back</a>
+ </li>
+ <li class="nav-item">
+ {{link(url_for("settings.details_GET",
+ username=job.owner.username,
+ job_id=job.id), "details")}}
+ </li>
+ </ul>
+ </div>
+</div>
+<div class="container">
+ {% block content %}{% endblock %}
+</div>
+{% endblock %}
diff --git a/buildsrht/templates/submit.html b/buildsrht/templates/submit.html
index a01d0a6..a3d4e72 100644
--- a/buildsrht/templates/submit.html
@@ -70,6 +70,46 @@
rows="{{note_rows}}"
>{{note if note else ""}}</textarea>
</div>
+ <fieldset class="form-group">
+ <legend>Visibility</legend>
+ <div class="form-check form-check-inline">
+ <label
+ class="form-check-label"
+ title="Publically visible and listed on your profile"
+ >
+ <input
+ class="form-check-input"
+ type="radio"
+ name="visibility"
+ value="PUBLIC"> Public
+ </label>
+ </div>
+ <div class="form-check form-check-inline">
+ <label
+ class="form-check-label"
+ title="Visible to anyone with the link, but not shown on your profile"
+ >
+ <input
+ class="form-check-input"
+ type="radio"
+ name="visibility"
+ value="UNLISTED"
+ checked> Unlisted
+ </label>
+ </div>
+ <div class="form-check form-check-inline">
+ <label
+ class="form-check-label"
+ title="Only visible to you and your collaborators"
+ >
+ <input
+ class="form-check-input"
+ type="radio"
+ name="visibility"
+ value="PRIVATE"> Private
+ </label>
+ </div>
+ </fieldset>
<div class="form-group">
<a
class="pull-right"
diff --git a/buildsrht/types/__init__.py b/buildsrht/types/__init__.py
index 84ec9aa..275eb76 100644
--- a/buildsrht/types/__init__.py
@@ -7,7 +7,7 @@ class User(Base, ExternalUserMixin):
class OAuthToken(Base, ExternalOAuthTokenMixin):
pass
-from .job import Job, JobStatus
+from .job import Job, JobStatus, Visibility
from .task import Task, TaskStatus
from .job_group import JobGroup
from .trigger import Trigger, TriggerType, TriggerCondition
diff --git a/buildsrht/types/job.py b/buildsrht/types/job.py
index 2ef807e..550b771 100644
--- a/buildsrht/types/job.py
@@ -13,6 +13,11 @@ class JobStatus(Enum):
timeout = 'timeout'
cancelled = 'cancelled'
+class Visibility(Enum):
+ PUBLIC = 'PUBLIC'
+ UNLISTED = 'UNLISTED'
+ PRIVATE = 'PRIVATE'
+
class Job(Base):
__tablename__ = 'job'
id = sa.Column(sa.Integer, primary_key=True)
@@ -32,6 +37,7 @@ class Job(Base):
nullable=False,
default=JobStatus.pending)
image = sa.Column(sa.String(256))
+ visibility = sa.Column(sau.ChoiceType(Visibility), nullable=False)
def __init__(self, owner, manifest):
self.owner_id = owner.id
diff --git a/schema.sql b/schema.sql
index 919e816..073467c 100644
--- a/schema.sql
+++ b/schema.sql
@@ -10,6 +10,12 @@ CREATE TYPE webhook_event AS ENUM (
'JOB_CREATED'
);
+CREATE TYPE visibility AS ENUM (
+ 'PUBLIC',
+ 'UNLISTED',
+ 'PRIVATE'
+);
+
CREATE TABLE "user" (
id serial PRIMARY KEY,
username character varying(256) UNIQUE,
@@ -64,7 +70,8 @@ CREATE TABLE job (
runner character varying,
status character varying NOT NULL,
secrets boolean DEFAULT true NOT NULL,
- image character varying(128)
+ image character varying(128),
+ visibility visibility NOT NULL
);
CREATE INDEX ix_job_owner_id ON job USING btree (owner_id);
base-commit: 27a80ffed5c357317a75c35bf210fe89b53c0d4c
--
2.39.2