Compare commits

...

138 Commits

Author SHA1 Message Date
Aravinth Manivannan b2eeb5b9fd
chore: lints
ci/woodpecker/push/woodpecker Pipeline was successful Details
2022-12-28 16:10:35 +05:30
Aravinth Manivannan 946a51e3ad
feat: s/gitea/forgejo/ 2022-12-28 16:08:24 +05:30
Aravinth Manivannan e4a8a6f5e4
fix: many-to-one repository mapping.
ci/woodpecker/push/woodpecker Pipeline failed Details
DESCRIPTION
    A repository can have multiple deployments, this caused problems
    when linking webhooks to repositories. This patch links a webhook to
    all available deployments from the repository.
2022-12-28 14:07:09 +05:30
Aravinth Manivannan d919bad570
feat: test webhooks
ci/woodpecker/push/woodpecker Pipeline was successful Details
2022-12-28 13:36:30 +05:30
Aravinth Manivannan 0c6199494b
feat: install gitea webhook security deps and load gitea webhook module
ci/woodpecker/push/woodpecker Pipeline was successful Details
2022-12-28 04:39:13 +05:30
Aravinth Manivannan 745b1eb0d5
feat: define gitea webhook routes 2022-12-28 04:38:57 +05:30
Aravinth Manivannan 8b19a8cac5
chore: reuse app context's http client in conductor obj 2022-12-28 04:38:24 +05:30
Aravinth Manivannan 3d11bfdcfc
feat: add gitea webhook template list REST API view 2022-12-28 04:37:42 +05:30
Aravinth Manivannan 0e5db5c7a9
feat: add gitea webhook template inspect web view 2022-12-28 04:37:09 +05:30
Aravinth Manivannan b6d53c9937
feat: add gitea webhook template list web view 2022-12-28 04:36:54 +05:30
Aravinth Manivannan e97712312f
fix: web: favicon alt-text crowding 2022-12-28 04:36:39 +05:30
Aravinth Manivannan 70e4650876
feat: add gitea webhook template and web view 2022-12-28 04:35:57 +05:30
Aravinth Manivannan e423ccc0ee
feat: REST API: list all webhooks created by user 2022-12-28 03:42:44 +05:30
Aravinth Manivannan 5d4977f421
feat: db: list all webhooks created by user 2022-12-28 03:42:00 +05:30
Aravinth Manivannan 201032fd07
feat: REST API: add and view webhooks 2022-12-28 03:22:23 +05:30
Aravinth Manivannan bce25be282
feat: db: get webhook with owner's username 2022-12-28 03:21:42 +05:30
Aravinth Manivannan 51e3924d71
feat: db: get site details from repository URL 2022-12-27 20:15:15 +05:30
Aravinth Manivannan f26075b881
feat: register new gitea webhooks 2022-12-20 07:11:21 +05:30
Aravinth Manivannan b93373e96b
fix: CI: build release profile bin and publish
ci/woodpecker/push/woodpecker Pipeline is pending Details
2022-12-19 15:15:18 +05:30
Aravinth Manivannan 4b1535848f
fix: rm cmd in publisher
ci/woodpecker/push/woodpecker Pipeline failed Details
2022-12-19 14:16:46 +05:30
Aravinth Manivannan f205543c02
fix: CI: don't purge publisher img
ci/woodpecker/push/woodpecker Pipeline failed Details
2022-12-19 13:52:30 +05:30
Aravinth Manivannan cfc3f81989
feat: CI: bin publish
ci/woodpecker/push/woodpecker Pipeline failed Details
2022-12-19 13:34:42 +05:30
Aravinth Manivannan 88aa76c55b
feat: bin publisher docker img. Get bin from realaravinth/librepages 2022-12-19 13:34:29 +05:30
Aravinth Manivannan ee23632e90
fix: bulid with DATABASE_URL unset and apply migrations
ci/woodpecker/push/woodpecker Pipeline failed Details
2022-12-19 12:50:06 +05:30
Aravinth Manivannan 98e3a9d810
fix: switch to global env vars
ci/woodpecker/push/woodpecker Pipeline failed Details
2022-12-19 12:43:13 +05:30
Aravinth Manivannan 631849fceb
fix: override DATABASE_URL _after_ URL is constructed
ci/woodpecker/push/woodpecker Pipeline failed Details
2022-12-19 12:35:10 +05:30
Aravinth Manivannan e1c475d05a
debug: CI: switch to local env vars for passing DATABASE_URL
ci/woodpecker/push/woodpecker Pipeline failed Details
2022-12-19 12:31:12 +05:30
Aravinth Manivannan a0144bcf9b
debug: CI: explicitly supply DATABASE_URL
ci/woodpecker/push/woodpecker Pipeline failed Details
2022-12-19 12:00:53 +05:30
Aravinth Manivannan 7db1fa3d3b
debug: db: url
ci/woodpecker/push/woodpecker Pipeline failed Details
2022-12-19 11:49:42 +05:30
Aravinth Manivannan fdf31ecfd2
debug: CI: switch name back to database and print URI 2022-12-19 10:57:30 +05:30
Aravinth Manivannan 0a05fb945f
debug: CI: rm db port from url
ci/woodpecker/push/woodpecker Pipeline failed Details
2022-12-19 10:48:45 +05:30
Aravinth Manivannan fb967e554b
debug: CI: pass DB url as string
ci/woodpecker/push/woodpecker Pipeline failed Details
2022-12-19 10:42:45 +05:30
Aravinth Manivannan b249696776
debug: CI: use different hostname for postgres container
ci/woodpecker/push/woodpecker Pipeline failed Details
2022-12-19 10:34:51 +05:30
Aravinth Manivannan 55e4c95d6c
fix: checkin sqlx offline compilation data
ci/woodpecker/push/woodpecker Pipeline failed Details
2022-12-19 10:24:51 +05:30
Aravinth Manivannan 56c1a5373e
fix: CI: unset DATABASE_URL while fetching deps 2022-12-19 09:49:18 +05:30
Aravinth Manivannan f021e6fa87
fix: update sqlx offline compilation data
ci/woodpecker/push/woodpecker Pipeline failed Details
2022-12-19 09:14:35 +05:30
Aravinth Manivannan 97b59a7576
fix: CI: sed command
ci/woodpecker/push/woodpecker Pipeline failed Details
2022-12-19 09:04:38 +05:30
Aravinth Manivannan 409b2be66d
fix: CI: rm release build step, use binary from docker img
ci/woodpecker/push/woodpecker Pipeline failed Details
2022-12-19 08:51:52 +05:30
Aravinth Manivannan abe3cf1dac
fix: CI: use the right image to publish bins 2022-12-19 08:51:40 +05:30
Aravinth Manivannan 87f39721c9
fix: CI: write sed output to config file & rm init conductor from make dev-env
ci/woodpecker/push/woodpecker Pipeline failed Details
2022-12-19 08:45:27 +05:30
Aravinth Manivannan 61fe325db0
fix: CI: source code uri
ci/woodpecker/push/woodpecker Pipeline failed Details
2022-12-19 08:41:34 +05:30
Aravinth Manivannan f919dcb691
fix: CI: fix conductor type
ci/woodpecker/push/woodpecker Pipeline failed Details
2022-12-19 08:38:06 +05:30
Aravinth Manivannan 04e3ab398d
fix: CI: booleans
ci/woodpecker/push/woodpecker Pipeline failed Details
2022-12-19 08:36:01 +05:30
Aravinth Manivannan 20e326fb51
fix: CI: port is a number
ci/woodpecker/push/woodpecker Pipeline failed Details
2022-12-19 08:34:25 +05:30
Aravinth Manivannan df3ed747b0
fix: CI: add librepages-conductor img
ci/woodpecker/push/woodpecker Pipeline failed Details
2022-12-19 08:33:48 +05:30
Aravinth Manivannan 4250a62165
feat: add CI build status badge 2022-12-19 08:27:28 +05:30
Aravinth Manivannan 321ff0beb8
fix: cache-buster link 2022-12-19 06:37:11 +05:30
Aravinth Manivannan 7f58f58651
chore: rename pages -> librepages 2022-12-19 06:32:51 +05:30
Aravinth Manivannan 0f44135f73
fix: CI: use github.com/mcaptcha/website for testing 2022-12-15 01:13:24 +05:30
Aravinth Manivannan 5350b22ffe
feat: CI: configure and run conductor instance while testing 2022-12-15 01:01:59 +05:30
Aravinth Manivannan 8a2ada891e
feat: run conductor instance while testing 2022-12-15 01:01:53 +05:30
Aravinth Manivannan be3af0e1fa
feat: relay site events to conductor 2022-12-15 01:00:15 +05:30
Aravinth Manivannan b7be2811d9
feat: talk to conductor 2022-12-15 00:59:52 +05:30
Aravinth Manivannan 59b619f6fd
feat: accept conductor configuration 2022-12-15 00:59:35 +05:30
Aravinth Manivannan 5c0f6fd84d
feat: use librepages/libconfig for handling deployment configurations 2022-12-09 14:24:02 +05:30
Aravinth Manivannan 538bc41113
feat: fire and test deployment deletion event when deployment is deleted 2022-12-06 06:30:56 +05:30
Aravinth Manivannan 3594d4e23b
feat: tombstone deployments instead of deleting them.
It is probably worthwhile storing deleted deployment('s events) for a
bit, before completely wiping them clean. Tombstone allows us to do
that.
2022-12-06 06:29:56 +05:30
Aravinth Manivannan ee6af46ccf
todo: erect deployment deletion tomstone 2022-12-05 19:15:49 +05:30
Aravinth Manivannan f111b5c8bf
feat: delete deployment from web UI
closes: #13
2022-12-05 17:40:38 +05:30
Aravinth Manivannan 398f08c07a
feat: view site details, deploy secret and latest events
closes: #10
2022-12-05 15:48:16 +05:30
Aravinth Manivannan 5917e5e29f
feat: DB: get site from deploy secret 2022-12-05 15:45:52 +05:30
Aravinth Manivannan 7e17f9de0a
feat: add show and hide icons 2022-12-05 15:45:04 +05:30
Aravinth Manivannan 7bf28f0f93
feat: use latest update event DB method when rendering dash home 2022-12-03 17:46:39 +05:30
Aravinth Manivannan c5ed6bf6c2
feat: add generic method to get latest event of any registered type and
get latest update event
2022-12-03 17:45:06 +05:30
Aravinth Manivannan d30d4e29b8
chore: rm debugging statement 2022-12-03 17:45:01 +05:30
Aravinth Manivannan bc7153a060
feat: add website from web UI
closes: #4 (comment)
2022-12-03 16:40:53 +05:30
Aravinth Manivannan ef55697879
fix: dashboard home title 2022-12-03 16:39:33 +05:30
Aravinth Manivannan 1f1b21baac
feat: add public ID to deployments 2022-12-03 16:30:33 +05:30
Aravinth Manivannan f56ca02d39
chore: lints 2022-12-03 16:12:19 +05:30
Aravinth Manivannan 9236451628
fix: link homepage in navbar 2022-12-03 15:47:46 +05:30
Aravinth Manivannan 330e835094
fix: test dashboard homepage and fix template vars in dash home 2022-12-03 15:31:40 +05:30
Aravinth Manivannan 6660602ab6
fix: redirect to dashboard homepage if user is authenticated 2022-12-03 14:57:08 +05:30
Aravinth Manivannan cdeabb06aa
feat: dashboard homepage. List existing deployments with add site btn 2022-12-03 14:56:17 +05:30
Aravinth Manivannan cbcd7bad7b
fix: rm loggedin_user link in authenticated nav bar component 2022-12-03 14:54:23 +05:30
Aravinth Manivannan 7dca981ee3
fix: style: broken fullscreen component 2022-12-03 14:54:12 +05:30
Aravinth Manivannan eae0a568e7
fix: duplicate title 2022-12-03 14:53:55 +05:30
Aravinth Manivannan 21bea52323
fix: revert back to using github.com for testing 2022-12-03 14:53:12 +05:30
Aravinth Manivannan 0f77f81f84
fix: use localhost for dev
actix_identity doesn't work otherwise
2022-12-03 14:52:45 +05:30
Aravinth Manivannan 2d9e952040
fix: load identity middleware 2022-12-03 14:03:24 +05:30
Aravinth Manivannan 1015ccbf4d
chore: refactor Event to be deserializ-able 2022-11-27 22:01:36 +05:30
Aravinth Manivannan 8a25459985
fix: duplicate home page. Redirect to login page, if user is
unauthenticated and redirect to dashboard homepage if user is
authenticated
2022-11-27 21:25:57 +05:30
Aravinth Manivannan 26fdc1db9f
chore: use common repo URL, as specified in src/tests.rs 2022-11-27 21:07:05 +05:30
Aravinth Manivannan 54c0323105
chore: mv deploy and meta API endpoints to src/api/v1 2022-11-27 21:06:42 +05:30
Aravinth Manivannan 20c3ee1f11
feat: test for unique event names and return event IDs on update API calls 2022-11-15 20:51:34 +05:30
Aravinth Manivannan f89b3e6d4c
feat: log site deploy ,update and delete events in db 2022-11-15 20:24:13 +05:30
Aravinth Manivannan 1e0fa7279f
chore: apply clippy lints 2022-11-15 18:09:34 +05:30
Aravinth Manivannan b07f076634
feat: read config from repository on every deploy and deployment update 2022-11-12 15:51:53 +05:30
Aravinth Manivannan 2d9d511bb8
feat: read configuration from repositories
ref: #8
2022-11-12 15:51:34 +05:30
Aravinth Manivannan ccb0ac9d09
feat: test util: accept custom filename and content in write_file_util 2022-11-12 15:50:55 +05:30
Aravinth Manivannan 3c3ff0f8a7
feat: report 404 when file not found in Git repo 2022-11-12 14:27:05 +05:30
Aravinth Manivannan 3a961bc524
feat: add tracing log identifier to each HTTP route handler 2022-11-11 15:37:33 +05:30
Aravinth Manivannan 0b2db58483
feat: replace log crate with tracing 2022-11-11 14:56:36 +05:30
Aravinth Manivannan 58bb606879
feat: serve requests on auto-assigned default deployment hostnames
TODO: serving custom domain requests are not yet implemented
2022-11-10 17:36:01 +05:30
Aravinth Manivannan ed68b4570c
feat: auto assign default deployment hostnames using crate::subdomains
utils
2022-11-10 17:35:48 +05:30
Aravinth Manivannan 30be3a293d
feat: use settings.page.base_domain to generate default deployment hostname 2022-11-10 17:34:21 +05:30
Aravinth Manivannan dd38dd05d1
feat: add base_path to settings to specify deploy host name.
DESCRIPTION
    Each deployment should have a default hostname before a custom
    domain can be assigned. Therefore, this domain must be in control of
    the Librepages system (Librepages/conductor, namely)

SECURITY
    base_domain must be different from the domains hosting confidential
    information (authentication systems, PII data, etc.) to make use of
    browser domain sandboxing safety. If Librepages deployment is open
    to the public, unaudited, third-party content may be hosted in this
    domain, so it is very important that this domain shouldn't be used
    for critical infrastructure dealing with confidential information.
2022-11-10 17:19:35 +05:30
Aravinth Manivannan 344cc85935
feat: construct random subdomains from wordlists.
SUMMARY
    Using data 1) and approach 2) mentioned here[0]

[0]: #5 (comment)
2022-11-10 17:02:41 +05:30
Aravinth Manivannan 926cf3fe08
fix: CI: install node and sass in coverage workflow 2022-11-10 16:30:02 +05:30
Aravinth Manivannan 79a8b6586c
feat: rm loading pages from settings and rely on DB. Propagate changes
across codebase
2022-11-10 16:26:19 +05:30
Aravinth Manivannan 4d7d2fd359
feat: controllers for adding and updating sites 2022-11-10 16:25:37 +05:30
Aravinth Manivannan e5450801c1
feat: add DB method to check if hostname exists 2022-11-10 16:19:28 +05:30
Aravinth Manivannan 74a33cf044
feat: test utils to add site 2022-11-10 16:18:52 +05:30
Aravinth Manivannan 7fb29e0d7a
fix: rm unique constraint on librepages_sites.repo_url
DESCRIPTION
    A repository might have several deployments. So unique repo_urls
    don't make any sense.
2022-11-10 16:11:56 +05:30
Aravinth Manivannan 8aa736da27
fix: use ServiceError::WebsiteNotFound for site related DB operations 2022-11-10 15:23:30 +05:30
Aravinth Manivannan c86ca3467f
feat: website FS path construction utility 2022-11-09 14:45:37 +05:30
Aravinth Manivannan ec7d698252
feat: db: get_site_from_secret for API authentication 2022-11-09 14:45:25 +05:30
Aravinth Manivannan 76692109bc
feat: add Settings.pages.base_path to store website content
DIRECTORY STRUCTURE:
    Settings.pages.base_path > page.hostname
2022-11-09 14:20:56 +05:30
Aravinth Manivannan 71533b9860
feat: db: add, rm, list and get site 2022-11-09 14:07:45 +05:30
Aravinth Manivannan c055cb30fd
feat: link to project homepage in footer 2022-11-09 13:32:21 +05:30
Aravinth Manivannan 1b9e1215c9
feat: bootstrap dashboard homepage 2022-11-09 13:32:06 +05:30
Aravinth Manivannan d9fc1b8533
feat: bootstrap homepage for unauthenticated visitors 2022-11-09 13:31:15 +05:30
Aravinth Manivannan d994400ff1
feat: speed up DB migrations sub cmd 2022-09-21 18:01:04 +05:30
Aravinth Manivannan 7a8808f95c
feat: process static files when building docker img 2022-09-16 18:28:25 +05:30
Aravinth Manivannan ba85626969
feat: cache bust before applying db migrations 2022-09-16 18:28:01 +05:30
Aravinth Manivannan 2e32f298df
feat: setup sass compilation on CI 2022-09-16 17:42:27 +05:30
Aravinth Manivannan 0bf5a35673
feat: login and join HTML pages 2022-09-16 17:41:39 +05:30
Aravinth Manivannan a1caff7538
feat: setup sass compilation 2022-09-16 17:41:11 +05:30
Aravinth Manivannan 0020deceea
feat: setup cache busting of static assets 2022-09-16 17:40:49 +05:30
Aravinth Manivannan 928700bb54
feat: setup static files embedding and serving 2022-09-16 17:39:56 +05:30
Aravinth Manivannan 0d107609b3
feat: mv routes into main and extract serve from api services 2022-09-16 17:39:37 +05:30
Aravinth Manivannan 1ada27924e
feat: add support email config param 2022-09-16 14:49:13 +05:30
Aravinth Manivannan 04d7f872d5
feat: auth and account management API 2022-09-16 13:24:30 +05:30
Aravinth Manivannan 7185bac60b
feat: auth and account management 2022-09-16 13:23:06 +05:30
Aravinth Manivannan b7bceddac8
feat: make: serve on default 2022-09-16 13:22:45 +05:30
Aravinth Manivannan 90dabb206d
feat: init settings in main fn 2022-09-16 13:22:29 +05:30
Aravinth Manivannan e3996134e4
chore: refactor: use api::v1::services and tests::get_ctx 2022-09-12 01:39:37 +05:30
Aravinth Manivannan 94702b81b0
feat: load actix-identity middleware 2022-09-12 01:39:11 +05:30
Aravinth Manivannan 6451055e2b
feat: argon2_creds error handlling 2022-09-12 00:24:24 +05:30
Aravinth Manivannan 45073bb1a4
feat: add registration flag 2022-09-12 00:23:38 +05:30
Aravinth Manivannan d80274712d
feat: add health endpoint 2022-09-12 00:23:19 +05:30
Aravinth Manivannan 4e860fc253
feat: add random string gen util 2022-09-12 00:23:09 +05:30
Aravinth Manivannan d8f2f82ab4
fix: expose update_* db methods 2022-09-12 00:22:56 +05:30
Aravinth Manivannan f3fe356a88
fix: CI: use `:` instead of `=` in yml 2022-09-10 20:12:37 +05:30
Aravinth Manivannan a0e93f0287
feat: auth db ops 2022-09-10 20:08:59 +05:30
Aravinth Manivannan 5df38a9b3f
feat: imple CLI to migrate and serve 2022-09-10 19:29:37 +05:30
Aravinth Manivannan 6e38497bbb
feat: setup CI with database service 2022-09-10 19:29:22 +05:30
Aravinth Manivannan 8150aa9ca1
feat: init and load db 2022-09-10 19:21:49 +05:30
Aravinth Manivannan 086c4f5911
feat: replace my_codegen with actix_web_codegen_const_routes 2022-09-10 18:31:05 +05:30
117 changed files with 13440 additions and 551 deletions

View File

@ -1,4 +1,4 @@
/target
**/target/
tarpaulin-report.html
.env
cobertura.xml

View File

@ -20,6 +20,22 @@ jobs:
name: ${{ matrix.version }} - x86_64-unknown-linux-gnu
runs-on: ubuntu-latest
services:
postgres:
image: postgres
env:
POSTGRES_PASSWORD: password
POSTGRES_USER: postgres
POSTGRES_DB: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v2
- name: ⚡ Cache
@ -31,6 +47,14 @@ jobs:
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- uses: actions/setup-node@v2
with:
node-version: "16.x"
- uses: actions/setup-node@v2
with:
node-version: "16.x"
- name: Install ${{ matrix.version }}
uses: actions-rs/toolchain@v1
with:
@ -38,12 +62,28 @@ jobs:
profile: minimal
override: true
- uses: actions/setup-node@v2
with:
node-version: "16.x"
- name: download deps
run: make dev-env
env:
LPCONDUCTOR_CREDS_USERNAME: "librepages_api"
LPCONDUCTOR_CREDS_PASSWORD: "longrandomlygeneratedpassword"
- name: Apply migrations
run: make migrate
env:
DATABASE_URL: "postgres://postgres:password@localhost:5432/postgres"
- name: Generate coverage file
if: matrix.version == 'stable' && (github.ref == 'refs/heads/master' || github.event_name == 'pull_request')
uses: actions-rs/tarpaulin@v0.1
with:
args: '-t 1200'
env:
DATABASE_URL: "postgres://postgres:password@localhost:5432/postgres"
# GIT_HASH is dummy value. I guess build.rs is skipped in tarpaulin
# execution so this value is required for preventing meta tests from
# panicking

View File

@ -22,6 +22,21 @@ jobs:
name: ${{ matrix.version }} - x86_64-unknown-linux-gnu
runs-on: ubuntu-latest
services:
postgres:
image: postgres
env:
POSTGRES_PASSWORD: password
POSTGRES_USER: postgres
POSTGRES_DB: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v2
- name: ⚡ Cache
@ -34,6 +49,16 @@ jobs:
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- uses: actions/setup-node@v2
with:
node-version: "16.x"
- name: download deps
run: make dev-env
env:
LPCONDUCTOR_CREDS_USERNAME: "librepages_api"
LPCONDUCTOR_CREDS_PASSWORD: "longrandomlygeneratedpassword"
- name: configure GPG key
if: (github.ref == 'refs/heads/master' || github.event_name == 'push') && github.repository == 'realaravinth/librepages'
run: echo -n "$RELEASE_BOT_GPG_SIGNING_KEY" | gpg --batch --import --pinentry-mode loopback
@ -54,21 +79,25 @@ jobs:
profile: minimal
override: true
- name: build
run: make
- name: build and apply migrations
run: make migrate
env:
DATABASE_URL: "postgres://postgres:password@localhost:5432/postgres"
- name: run tests
run: make test
env:
DATABASE_URL: "postgres://postgres:password@localhost:5432/postgres"
- name: make docker images
run: make docker
- name: publish docker images
if: (github.ref == 'refs/heads/master' || github.event_name == 'push') && github.repository == 'realaravinth/librepages'
if: (github.ref == 'refs/heads/master' && github.event_name == 'push') && github.repository == 'realaravinth/librepages'
run: make docker-publish
- name: publish bins
if: (github.ref == 'refs/heads/master' || github.event_name == 'push') && github.repository == 'realaravinth/librepages'
if: (github.ref == 'refs/heads/master' && github.event_name == 'push') && github.repository == 'realaravinth/librepages'
run: ./scripts/bin-publish.sh publish master latest $DUMBSERVE_PASSWORD
env:
DUMBSERVE_PASSWORD: ${{ secrets.DUMBSERVE_PASSWORD }}
@ -79,6 +108,7 @@ jobs:
run: make doc
env:
GIT_HASH: 8e77345f1597e40c2e266cb4e6dee74888918a61 # dummy value
DATABASE_URL: "postgres://postgres:password@localhost:5432/postgres"
- name: Deploy to GitHub Pages
if: matrix.version == 'stable' && (github.repository == 'realaravinth/librepages')

6
.gitignore vendored
View File

@ -1,3 +1,9 @@
/target
tarpaulin-report.html
.env
.env.local
src/cache_buster_data.json
utils/cache-bust/src/cache_buster_data.json
node_modules/
static/cache/css/
assets/

82
.woodpecker.yml Normal file
View File

@ -0,0 +1,82 @@
pipeline:
backend:
image: rust
environment:
- DATABASE_URL=postgres://postgres:password@database:5432/postgres
commands:
- curl -fsSL https://deb.nodesource.com/setup_16.x | bash - &&\
- apt update && apt-get -y --no-install-recommends install nodejs tar gpg curl wget
- rustup component add rustfmt
- rustup component add clippy
# rewrite conducotr configuration
- sed -i 's%url = "http:\/\/localhost:5000"%url = "http:\/\/librepages-conductor:5000"%' config/default.toml
- make dev-env
- make migrate
- make lint
- make test
- make release
build_docker_img:
image: plugins/docker
when:
event: [pull_request]
settings:
dry_run: true
repo: realaravinth/librepages
tags: latest
build_and_publish_docker_img:
image: plugins/docker
when:
event: [push, tag, deployment]
settings:
username: realaravinth
password:
from_secret: DOCKER_TOKEN
repo: realaravinth/librepages
tags: latest
# build_publisher_docker_img:
# image: plugins/docker
# when:
# event: [push, tag, deployment]
# settings:
# dry_run: true
# dockerfile: scripts/publish-bins-docker
# purge: false
# repo: realaravinth/librepages-publisher
# tags: latest
#
publish_bins:
image: rust
when:
event: [push, tag, deployment]
commands:
- apt update
- apt-get -y --no-install-recommends install gpg tar curl wget
- echo -n "$RELEASE_BOT_GPG_SIGNING_KEY" | gpg --batch --import --pinentry-mode loopback
- scripts/bin-publish.sh publish master latest $DUMBSERVE_PASSWORD
secrets: [RELEASE_BOT_GPG_SIGNING_KEY, DUMBSERVE_PASSWORD, GPG_PASSWORD]
services:
database:
image: postgres
environment:
- POSTGRES_PASSWORD=password
librepages-conductor:
image: realaravinth/librepages-conductor
command: conductor serve
environment:
- LPCONDUCTOR_SERVER__PROXY_HAS_TLS=false
- LPCONDUCTOR_DEBUG=false
- LPCONDUCTOR_CONDUCTOR=dummy
- LPCONDUCTOR_SERVER_URL_PREFIX=""
- LPCONDUCTOR_SERVER_DOMAIN="librepages.test"
- LPCONDUCTOR_SERVER_IP=0.0.0.0
- LPCONDUCTOR_SERVER_PROXY_HAS_TLS=false
- LPCONDUCTOR_SERVER_PORT=7000
- LPCONDUCTOR_SOURCE_CODE=https://example.org
- LPCONDUCTOR_CREDS_USERNAME="librepages_api"
- LPCONDUCTOR_CREDS_PASSWORD="longrandomlygeneratedpassword"
- PORT=5000

2282
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,12 @@
[package]
name = "pages"
name = "librepages"
version = "0.1.0"
edition = "2021"
build = "build.rs"
homepage = "https://github.com/realaravinth/pages"
repository = "https://github.com/realaravinth/pages"
documentation = "https://github.con/realaravinth/pages"
readme = "https://github.com/realaravinth/pages/blob/master/README.md"
homepage = "https://git.batsense.net/LibrePages/librepages"
repository = "https://git.batsense.net/LibrePages/librepages"
documentation = "https://git.batsense.net/LibrePages/librepages"
readme = "https://git.batsense.net/LibrePages/librepages/blob/master/README.md"
license = "AGPLv3 or later version"
authors = ["realaravinth <realaravinth@batsense.net>"]
@ -15,8 +15,14 @@ authors = ["realaravinth <realaravinth@batsense.net>"]
[dependencies]
actix-web = "4.0.1"
actix-http = "3.0.4"
actix-identity = "0.4.0"
actix-rt = "2"
my-codegen = {package = "actix-web-codegen", git ="https://github.com/realaravinth/actix-web"}
actix-web-codegen-const-routes = { version = "0.1.0", tag = "0.1.0", git = "https://github.com/realaravinth/actix-web-codegen-const-routes" }
argon2-creds = { branch = "master", git = "https://github.com/realaravinth/argon2-creds"}
sqlx = { version = "0.6.2", features = ["runtime-actix-rustls", "postgres", "time", "offline", "json", "uuid"] }
clap = { version = "3.2.20", features = ["derive"]}
libconfig = { version = "0.1.0", git = "https://git.batsense.net/librepages/libconfig" }
libconductor = { version = "0.1.0", git = "https://git.batsense.net/librepages/conductor/" }
config = "0.13"
git2 = "0.14.2"
@ -25,11 +31,10 @@ serde = { version = "1", features = ["derive", "rc"]}
serde_json = "1"
pretty_env_logger = "0.4"
log = "0.4"
lazy_static = "1.4"
url = "2.2"
url = { version = "2.2", features = ["serde"] }
urlencoding = "2.1.0"
derive_more = "0.99"
@ -39,6 +44,37 @@ tokio = { version = "1", features=["sync"]}
num_enum = "0.5.7"
mime_guess = "2.0.4"
mime = "0.3.16"
rust-embed = "6.3.0"
rand = "0.8.5"
tracing = { version = "0.1.37", features = ["log"]}
tracing-actix-web = "0.6.2"
toml = "0.5.9"
serde_yaml = "0.9.14"
uuid = { version = "1.2.2", features = ["serde"] }
reqwest = { version = "0.11.13", features = ["json"] }
sha2 = "0.10.6"
hmac = "0.12.1"
hex= "0.4.3"
[dependencies.cache-buster]
git = "https://github.com/realaravinth/cache-buster"
[dependencies.tera]
default-features = false
version = "1.15.0"
[dependencies.actix-auth-middleware]
branch = "v4"
features = ["actix_identity_backend"]
git = "https://github.com/realaravinth/actix-auth-middleware"
version = "0.2"
[dev-dependencies]
futures = "0.3.24"
mktemp = "0.4.1"
[workspace]
exclude = ["utils/cache-bust"]

View File

@ -1,20 +1,29 @@
FROM node:16.9.1 as frontend
COPY package.json package-lock.json /src/
WORKDIR /src
RUN npm install
COPY . .
RUN npm run sass
FROM rust:slim as rust
WORKDIR /src
RUN apt-get update && apt-get install -y git pkg-config libssl-dev
RUN apt-get update && apt-get install -y git pkg-config libssl-dev make
RUN mkdir src && echo "fn main() {}" > src/main.rs
COPY Cargo.toml .
RUN sed -i '/.*build.rs.*/d' Cargo.toml
COPY Cargo.lock .
RUN cargo build --release
RUN cargo build --release || true
COPY --from=frontend /src/static/ /src/static/
COPY . /src
RUN cd utils/cache-bust && cargo run
RUN cargo build --release
FROM debian:bullseye-slim
#RUN useradd -ms /bin/bash -u 1000 pages
#RUN mkdir -p /var/www/pages && chown pages /var/www/pages
#RUN useradd -ms /bin/bash -u 1000 librepages
#RUN mkdir -p /var/www/librepages && chown librepages /var/www/librepages
RUN apt-get update && apt-get install -y ca-certificates
COPY scripts/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
COPY --from=rust /src/target/release/pages /usr/local/bin/
COPY --from=rust /src/target/release/librepages /usr/local/bin/
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

View File

@ -1,5 +1,14 @@
define cache_bust ## run cache_busting program
npm run sass
cd utils/cache-bust && cargo run
endef
default: ## Debug build
cargo build
$(call cache_bust)
cargo run -- serve
cache-bust: ## Run cache buster on static assets
$(call cache_bust)
check: ## Check for syntax errors on all workspaces
cargo check --workspace --tests --all-features
@ -9,9 +18,11 @@ clean: ## Clean all build artifacts and dependencies
@cargo clean
coverage: ## Generate HTML code coverage
$(call cache_bust)
cargo tarpaulin -t 1200 --out Html
dev-env: ## Download development dependencies
npm install
cargo fetch
doc: ## Prepare documentation
@ -19,29 +30,42 @@ doc: ## Prepare documentation
docker: ## Build docker images
docker build \
-t realaravinth/pages:master \
-t realaravinth/pages:latest \
-t realaravinth/pages:0.1.0 .
-t realaravinth/librepages:master \
-t realaravinth/librepages:latest \
-t realaravinth/librepages:0.1.0 .
docker-publish: docker ## Build and publish docker images
docker push realaravinth/pages:master
docker push realaravinth/pages:latest
docker push realaravinth/pages:0.1.0
docker push realaravinth/librepages:master
docker push realaravinth/librepages:latest
docker push realaravinth/librepages:0.1.0
lint: ## Lint codebase
cargo fmt -v --all -- --emit files
cargo clippy --workspace --tests --all-features
migrate: ## run migrations
$(call cache_bust)
unset DATABASE_URL && cargo build
DATABASE_URL=${DATABASE_URL} cargo run -- migrate
release: ## Release build
$(call cache_bust)
cargo build --release
run: default ## Run debug build
cargo run
cargo run -- serve
sqlx-offline-data: ## prepare sqlx offline data
cargo sqlx prepare \
--database-url=${DATABASE_URL} -- \
--all-features
test: ## Run tests
$(call cache_bust)
cargo test --all-features --no-fail-fast
xml-test-coverage: ## Generate cobertura.xml test coverage
$(call cache_bust)
cargo tarpaulin -t 1200 --out Xml
help: ## Prints help for targets with comments

View File

@ -5,10 +5,8 @@
**Auto-deploy static websites from git repositories**
</p>
[![status-badge](https://ci.batsense.net/api/badges/LibrePages/librepages/status.svg)](https://ci.batsense.net/LibrePages/librepages)
[![Build](https://github.com/realaravinth/pages/actions/workflows/linux.yml/badge.svg)](https://github.com/realaravinth/pages/actions/workflows/linux.yml)
[![dependency status](https://deps.rs/repo/github/realaravinth/pages/status.svg)](https://deps.rs/repo/github/realaravinth/pages)
[![codecov](https://codecov.io/gh/realaravinth/pages/branch/master/graph/badge.svg)](https://codecov.io/gh/realaravinth/pages)
</div>

View File

@ -18,7 +18,7 @@ use std::process::Command;
fn main() {
let output = Command::new("git")
.args(&["rev-parse", "HEAD"])
.args(["rev-parse", "HEAD"])
.output()
.expect("error in git command, is git installed?");
let git_hash = String::from_utf8(output.stdout).unwrap();

View File

@ -1,13 +1,10 @@
debug = true
allow_registration = true
# source code of your copy of pages server.
source_code = "https://github.com/realaravinth/pages"
# To deploy a website from a Git repository, please provide the following details:
# 1. branch: the branch in the Git repository which contains the website files
# 2. repo: the public readonly/clonable URL of the website repository
# 3. path: the directory where you'd like Pages to clone the specified repository
# 3. secret: a unique secret which is used to authenticate webhook calls
pages = [
{ branch = "gh-pages", domain="local.mcaptcha.org", repo = "https://github.com/mCaptcha/website/", path ="/tmp/pages/mcaptcha/website", secret = "faee1b650ac586068a54cb160bd6353c5e16be7c64b49113fe57726e5393" },
source_code = "https://git.batsense.net/LibrePages/pages"
support_email = "support@librepages.example.org"
conductors = [
{ username = "librepages_api", api_key = "longrandomlygeneratedpassword", url = "http://localhost:5000"}
]
[server]
@ -19,4 +16,25 @@ ip= "0.0.0.0"
# Minimum of two threads are advisable for top async performance but can work
# with one also.
workers = 2
domain = "demo.librepages.org"
domain = "localhost"
cookie_secret = "94b2b2732626fdb7736229a7c777cb451e6304c147c4549f30"
[page]
base_path = "/tmp/librepages-defualt-config/"
base_domain = "librepages.test" # domain where customer pages will be deployed.
[database]
# This section deals with the database location and how to access it
# Please note that at the moment, we have support for only postgresqa.
# Example, if you are Batman, your config would be:
# hostname = "batcave.org"
# port = "5432"
# username = "batman"
# password = "somereallycomplicatedBatmanpassword"
hostname = "localhost"
port = "5432"
username = "postgres"
password = "password"
name = "postgres"
pool = 4
database_type="postgres" # "postgres"

View File

@ -3,10 +3,10 @@
The process is tedious, most of this will be automated with a script in
the future.
## 1. Create new user for running `pages`:
## 1. Create new user for running `librepages`:
```bash
sudo useradd -b /srv -m -s /usr/bin/zsh pages
sudo useradd -b /srv -m -s /usr/bin/zsh librepages
```
## 2. Install Runtime dependencies
@ -19,11 +19,11 @@ On Debian-based systems, run:
sudo apt install nginx
```
## 3. Build `Pages`
## 3. Build `librepages`
### i. Install Build Dependencies
To build `pages`, you need the following dependencies:
To build `librepages`, you need the following dependencies:
1. [Git](https://packages.debian.org/bullseye/git)
2. [pkg-config](https://packages.debian.org/bullseye/pkg-config)
@ -71,23 +71,23 @@ Install binary and copy demo configuration file into default configuration
lookup path(`/etc/static-pages/config.toml`)
```bash
sudo cp ./target/release/pages /usr/local/bin/ && \
sudo cp ./target/release/librepages /usr/local/bin/ && \
sudo mkdir /etc/static-pages && \
sudo cp config/default.toml /etc/static-pages/config.toml
```
## 4. Systemd service configuration:
### i. Copy the following to `/etc/systemd/system/pages.service`:
### i. Copy the following to `/etc/systemd/system/librepages.service`:
```systemd
[Unit]
Description=pages: Auto-deploy static websites from git repositories
Description=librepages: Auto-deploy static websites from git repositories
[Service]
Type=simple
User=pages
ExecStart=/usr/local/bin/pages
User=librepages
ExecStart=/usr/local/bin/librepages
Restart=on-failure
RestartSec=1
MemoryDenyWriteExecute=true
@ -107,13 +107,13 @@ WantedBy=multi-user.target
```bash
sudo systemctl daemon-reload && \
sudo systemctl enable pages && \ # Auto startup during boot
sudo systemctl start pages
sudo systemctl enable librepages && \ # Auto startup during boot
sudo systemctl start librepages
```
## 5. Optionally configure Nginx to reverse proxy requests to Pages
## 5. Optionally configure Nginx to reverse proxy requests to LibrePages
**NOTE: This sections includes instructions to reverse proxy requests to
Pages API, not the websites managed by Pages.**
LibrePages API, not the websites managed by librepages.**
See [here](../../config/pages-nginx-config) for sample Nginx configuration.
See [here](../../config/librepages-nginx-config) for sample Nginx configuration.

View File

@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS librepages_users (
name VARCHAR(100) NOT NULL UNIQUE,
email VARCHAR(100) UNIQUE NOT NULL,
email_verified BOOLEAN DEFAULT NULL,
password TEXT NOT NULL,
ID SERIAL PRIMARY KEY NOT NULL
);

View File

@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS librepages_sites (
site_secret VARCHAR(32) NOT NULL UNIQUE,
repo_url VARCHAR(3000) NOT NULL,
branch TEXT NOT NULL,
hostname VARCHAR(3000) NOT NULL UNIQUE,
pub_id uuid NOT NULL UNIQUE,
ID SERIAL PRIMARY KEY NOT NULL,
deleted BOOLEAN DEFAULT FALSE,
owned_by INTEGER NOT NULL references librepages_users(ID) ON DELETE CASCADE
);
CREATE UNIQUE INDEX librepages_sites_site_secret ON librepages_sites(site_secret);
CREATE UNIQUE INDEX librepages_sites_site_pub_id ON librepages_sites(pub_id);

View File

@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS librepages_deploy_event_type (
name VARCHAR(30) NOT NULL UNIQUE,
ID SERIAL PRIMARY KEY NOT NULL
);
CREATE UNIQUE INDEX librepages_deploy_event_name_index ON librepages_deploy_event_type(name);
CREATE TABLE IF NOT EXISTS librepages_site_deploy_events (
site INTEGER NOT NULL references librepages_sites(ID) ON DELETE CASCADE,
event_type INTEGER NOT NULL references librepages_deploy_event_type(ID),
time timestamptz NOT NULL,
pub_id uuid NOT NULL UNIQUE,
ID SERIAL PRIMARY KEY NOT NULL
);

View File

@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS librepages_forgejo_webhooks (
forgejo_webhook_secret VARCHAR(40) NOT NULL UNIQUE,
forgejo_url VARCHAR(3000) NOT NULL,
auth_token VARCHAR(40) NOT NULL UNIQUE,
ID SERIAL PRIMARY KEY NOT NULL,
owned_by INTEGER NOT NULL references librepages_users(ID) ON DELETE CASCADE
);
CREATE UNIQUE INDEX librepages_forgejo_webhook_auth_token_index ON librepages_forgejo_webhooks(auth_token);
CREATE TABLE IF NOT EXISTS librepages_forgejo_webhook_site_mapping (
site_id INTEGER NOT NULL references librepages_sites(ID) ON DELETE CASCADE,
forgejo_webhook_id INTEGER NOT NULL references librepages_forgejo_webhooks(ID) ON DELETE CASCADE,
UNIQUE(site_id, forgejo_webhook_id)
);

385
package-lock.json generated Normal file
View File

@ -0,0 +1,385 @@
{
"name": "librepages",
"version": "1.0.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "librepages",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"sass": "^1.54.9"
}
},
"node_modules/anymatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
"integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==",
"dev": true,
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/binary-extensions": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
"integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/braces": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
"dev": true,
"dependencies": {
"fill-range": "^7.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/chokidar": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
"integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
],
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
"dev": true,
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/immutable": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.1.0.tgz",
"integrity": "sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ==",
"dev": true
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"dependencies": {
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/sass": {
"version": "1.54.9",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.54.9.tgz",
"integrity": "sha512-xb1hjASzEH+0L0WI9oFjqhRi51t/gagWnxLiwUNMltA0Ab6jIDkAacgKiGYKM9Jhy109osM7woEEai6SXeJo5Q==",
"dev": true,
"dependencies": {
"chokidar": ">=3.0.0 <4.0.0",
"immutable": "^4.0.0",
"source-map-js": ">=0.6.2 <2.0.0"
},
"bin": {
"sass": "sass.js"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/source-map-js": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
}
},
"dependencies": {
"anymatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
"integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==",
"dev": true,
"requires": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
}
},
"binary-extensions": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
"integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
"dev": true
},
"braces": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
"dev": true,
"requires": {
"fill-range": "^7.0.1"
}
},
"chokidar": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
"integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
"dev": true,
"requires": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"fsevents": "~2.3.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
}
},
"fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
"dev": true,
"requires": {
"to-regex-range": "^5.0.1"
}
},
"fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"optional": true
},
"glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"requires": {
"is-glob": "^4.0.1"
}
},
"immutable": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.1.0.tgz",
"integrity": "sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ==",
"dev": true
},
"is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"requires": {
"binary-extensions": "^2.0.0"
}
},
"is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true
},
"is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"requires": {
"is-extglob": "^2.1.1"
}
},
"is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true
},
"normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true
},
"picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true
},
"readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"requires": {
"picomatch": "^2.2.1"
}
},
"sass": {
"version": "1.54.9",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.54.9.tgz",
"integrity": "sha512-xb1hjASzEH+0L0WI9oFjqhRi51t/gagWnxLiwUNMltA0Ab6jIDkAacgKiGYKM9Jhy109osM7woEEai6SXeJo5Q==",
"dev": true,
"requires": {
"chokidar": ">=3.0.0 <4.0.0",
"immutable": "^4.0.0",
"source-map-js": ">=0.6.2 <2.0.0"
}
},
"source-map-js": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
"dev": true
},
"to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"requires": {
"is-number": "^7.0.0"
}
}
}
}

25
package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "librepages",
"version": "1.0.0",
"description": "<div align=\"center\"> <h1> Pages </h1> <p>",
"main": "index.js",
"directories": {
"doc": "docs"
},
"scripts": {
"sass": "rm -rf static/cache/css/*.css && sass templates/main.scss static/cache/css/main.css && sass templates/mobile.scss static/cache/css/mobile.css"
},
"repository": {
"type": "git",
"url": "git+https://github.com/realaravinth/librepages.git"
},
"author": "",
"license": "ISC",
"bugs": {
"url": "https://github.com/realaravinth/librepages/issues"
},
"homepage": "https://github.com/realaravinth/librepages#readme",
"devDependencies": {
"sass": "^1.54.9"
}
}

View File

@ -33,14 +33,15 @@ FILENAME="$NAME-$2-linux-amd64"
TARBALL=$FILENAME.tar.gz
TARGET_DIR="$TMP_DIR/$FILENAME/"
mkdir -p $TARGET_DIR
DOCKER_IMG="realaravinth/pages:$3"
DOCKER_IMG="realaravinth/librepages:$3"
get_bin(){
echo "[*] Grabbing binary"
container_id=$(docker create $DOCKER_IMG)
docker cp $container_id:/usr/local/bin/pages $TARGET_DIR/
docker rm -v $container_id
#container_id=$(docker create $DOCKER_IMG)
#docker cp $container_id:/usr/local/bin/pages $TARGET_DIR/
#docker rm -v $container_id
cp target/release/librepages $TARGET_DIR
}
copy() {

23
scripts/conductor.sh Executable file
View File

@ -0,0 +1,23 @@
#!/bin/bash
readonly NAME=librepages-conductor
docker rm -f $NAME
docker create --name $NAME -p 5000:5000 \
-e LPCONDUCTOR__SOURCE_CODE="https://git.batsense.net/LibrePages/conductor" \
-e LPCONDUCTOR_SERVER__PROXY_HAS_TLS=false \
-e LPCONDUCTOR_DEBUG="false" \
-e LPCONDUCTOR_CONDUCTOR="dummy" \
-e LPCONDUCTOR_SERVER_URL_PREFIX="" \
-e LPCONDUCTOR_SERVER_DOMAIN="librepages.test" \
-e LPCONDUCTOR_SERVER_IP="0.0.0.0" \
-e LPCONDUCTOR_SERVER_PROXY_HAS_TLS="false" \
-e LPCONDUCTOR_SERVER_PORT=7000 \
-e LPCONDUCTOR_SOURCE_CODE="https://example.org" \
-e LPCONDUCTOR_CREDS_USERNAME=$LPCONDUCTOR_CREDS_USERNAME \
-e LPCONDUCTOR_CREDS_PASSWORD=$LPCONDUCTOR_CREDS_PASSWORD \
-e PORT="5000"\
realaravinth/librepages-conductor conductor serve
docker start $NAME

View File

@ -15,4 +15,4 @@ else
useradd --uid $USER_ID -b /home -m -s /bin/bash $LIBREPAGES_USER
fi
su $LIBREPAGES_USER -c 'pages'
su $LIBREPAGES_USER -c 'librepages'

View File

@ -0,0 +1,14 @@
FROM realaravinth/librepages:latest as base
RUN echo foo
FROM debian:bullseye-slim
RUN apt update
RUN apt-get -y --no-install-recommends install gpg tar curl wget
WORKDIR /src
COPY --from=base /usr/local/bin/librepages .
COPY . .
ARG RELEASE_BOT_GPG_SIGNING_KEY
RUN echo -n "$RELEASE_BOT_GPG_SIGNING_KEY"
RUN echo -n "$RELEASE_BOT_GPG_SIGNING_KEY" | gpg --batch --import --pinentry-mode loopback
env GPG_PASSWORD=$GPG_PASSWORD
RUN /src/scripts/bin-publish.sh publish master latest $DUMBSERVE_PASSWORD

783
sqlx-data.json Normal file
View File

@ -0,0 +1,783 @@
{
"db": "PostgreSQL",
"10d30dade86d79210203bdbce4b6db5d2aa446b0f88ca834771ecbbe11be51fb": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Text",
"Text"
]
}
},
"query": "UPDATE librepages_sites SET deleted = true\n WHERE hostname = ($1)\n AND owned_by = ( SELECT ID FROM librepages_users WHERE name = $2);\n "
},
"12391b10cf16a807322c49c9cc7e0a015f26b445beacf4cdd4e7714f36b4adf6": {
"describe": {
"columns": [
{
"name": "site_secret",
"ordinal": 0,
"type_info": "Varchar"
},
{
"name": "repo_url",
"ordinal": 1,
"type_info": "Varchar"
},
{
"name": "branch",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "hostname",
"ordinal": 3,
"type_info": "Varchar"
},
{
"name": "pub_id",
"ordinal": 4,
"type_info": "Uuid"
}
],
"nullable": [
false,
false,
false,
false,
false
],
"parameters": {
"Left": [
"Text"
]
}
},
"query": "SELECT site_secret, repo_url, branch, hostname, pub_id\n FROM librepages_sites\n WHERE deleted = false\n AND owned_by = (SELECT ID FROM librepages_users WHERE name = $1 );\n "
},
"14cdc724af64942e93994f97e9eafc8272d15605eff7aab9e5177d01f2bf6118": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Text",
"Timestamptz",
"Text",
"Uuid"
]
}
},
"query": "INSERT INTO librepages_site_deploy_events\n (event_type, time, site, pub_id) VALUES (\n (SELECT iD from librepages_deploy_event_type WHERE name = $1),\n $2,\n (SELECT ID from librepages_sites WHERE hostname = $3),\n $4\n );\n "
},
"1be33ea4fe0e6079c88768ff912b824f4b0250193f2d086046c1fd0da125ae0c": {
"describe": {
"columns": [
{
"name": "name",
"ordinal": 0,
"type_info": "Varchar"
},
{
"name": "password",
"ordinal": 1,
"type_info": "Text"
}
],
"nullable": [
false,
false
],
"parameters": {
"Left": [
"Text"
]
}
},
"query": "SELECT name, password FROM librepages_users WHERE name = ($1)"
},
"279b5ae27935279b06d2799eef2da6a316324a05d23ba7a729c608c70168c496": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
}
},
"query": "UPDATE librepages_users set name = $1\n WHERE name = $2"
},
"39854fcbfb0247377c6c5ca70c2c0fa7804548848bf56f881eea2f8242e7a09d": {
"describe": {
"columns": [
{
"name": "name",
"ordinal": 0,
"type_info": "Varchar"
},
{
"name": "time",
"ordinal": 1,
"type_info": "Timestamptz"
},
{
"name": "pub_id",
"ordinal": 2,
"type_info": "Uuid"
}
],
"nullable": [
false,
false,
false
],
"parameters": {
"Left": [
"Text",
"Uuid"
]
}
},
"query": "SELECT\n librepages_deploy_event_type.name,\n librepages_site_deploy_events.time,\n librepages_site_deploy_events.pub_id\n FROM\n librepages_site_deploy_events\n INNER JOIN librepages_deploy_event_type ON\n librepages_deploy_event_type.ID = librepages_site_deploy_events.event_type\n WHERE\n librepages_site_deploy_events.site = (\n SELECT ID FROM librepages_sites WHERE hostname = $1\n )\n AND\n librepages_site_deploy_events.pub_id = $2\n "
},
"3ecc3a4c89b1289368ef9d9c97204330f74138a0da614ef2174c59a687119595": {
"describe": {
"columns": [
{
"name": "forgejo_url",
"ordinal": 0,
"type_info": "Varchar"
},
{
"name": "auth_token",
"ordinal": 1,
"type_info": "Varchar"
},
{
"name": "forgejo_webhook_secret",
"ordinal": 2,
"type_info": "Varchar"
}
],
"nullable": [
false,
false,
false
],
"parameters": {
"Left": [
"Text",
"Text"
]
}
},
"query": "SELECT\n forgejo_url, auth_token, forgejo_webhook_secret\n FROM\n librepages_forgejo_webhooks\n WHERE\n auth_token = $1\n AND\n owned_by = (SELECT ID FROM librepages_users WHERE name = $2);\n "
},
"432fe829719ce8110f768b4a611724bb34191ac224d2143ff4c81548da75c103": {
"describe": {
"columns": [
{
"name": "repo_url",
"ordinal": 0,
"type_info": "Varchar"
},
{
"name": "branch",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "hostname",
"ordinal": 2,
"type_info": "Varchar"
},
{
"name": "owned_by",
"ordinal": 3,
"type_info": "Int4"
},
{
"name": "site_secret",
"ordinal": 4,
"type_info": "Varchar"
}
],
"nullable": [
false,
false,
false,
false,
false
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
}
},
"query": "SELECT repo_url, branch, hostname, owned_by, site_secret\n FROM librepages_sites\n WHERE pub_id = $1\n AND\n owned_by = (SELECT ID from librepages_users WHERE name = $2)\n AND\n deleted = false;\n "
},
"4445ff3226af3b5a24b255c5bb012c99b222cc7bd6dda80f232809ed273fc712": {
"describe": {
"columns": [
{
"name": "repo_url",
"ordinal": 0,
"type_info": "Varchar"
},
{
"name": "site_secret",
"ordinal": 1,
"type_info": "Varchar"
},
{
"name": "branch",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "hostname",
"ordinal": 3,
"type_info": "Varchar"
},
{
"name": "owned_by",
"ordinal": 4,
"type_info": "Int4"
},
{
"name": "pub_id",
"ordinal": 5,
"type_info": "Uuid"
}
],
"nullable": [
false,
false,
false,
false,
false,
false
],
"parameters": {
"Left": [
"Text"
]
}
},
"query": "SELECT repo_url, site_secret, branch, hostname, owned_by, pub_id\n FROM librepages_sites\n WHERE repo_url = $1\n AND deleted = false;\n "
},
"4cddf1049783251bfc79090055724e894a2be9451302f7691ce2f4240f1ee3ad": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Int4"
}
],
"nullable": [
false
],
"parameters": {
"Left": [
"Text"
]
}
},
"query": "SELECT ID FROM librepages_sites WHERE repo_url = $1"
},
"53f3c21c06c8d1c218537dfa9183fd0604aaf28200d8aa12e97db4ac317df39e": {
"describe": {
"columns": [
{
"name": "name",
"ordinal": 0,
"type_info": "Varchar"
}
],
"nullable": [
false
],
"parameters": {
"Left": [
"Int4"
]
}
},
"query": "SELECT name FROM librepages_users WHERE ID = $1"
},
"54f1ad328c83997d5e80686665d4cfef58d3529d24cb6caaa7ff301479e5d735": {
"describe": {
"columns": [
{
"name": "repo_url",
"ordinal": 0,
"type_info": "Varchar"
},
{
"name": "branch",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "hostname",
"ordinal": 2,
"type_info": "Varchar"
},
{
"name": "owned_by",
"ordinal": 3,
"type_info": "Int4"
},
{
"name": "pub_id",
"ordinal": 4,
"type_info": "Uuid"
}
],
"nullable": [
false,
false,
false,
false,
false
],
"parameters": {
"Left": [
"Text"
]
}
},
"query": "SELECT repo_url, branch, hostname, owned_by, pub_id\n FROM librepages_sites\n WHERE site_secret = $1\n AND deleted = false;\n "
},
"5c5d774bde06c0ab83c3616a56a28f12dfd9c546cbaac9f246d3b350c587823e": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Text"
]
}
},
"query": "DELETE FROM librepages_users WHERE name = ($1)"
},
"65f6181364cd8c6ed4eae3f62b5ae771a27e8da6e698c235ca77d4cec784d04b": {
"describe": {
"columns": [
{
"name": "site_secret",
"ordinal": 0,
"type_info": "Varchar"
},
{
"name": "repo_url",
"ordinal": 1,
"type_info": "Varchar"
},
{
"name": "branch",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "hostname",
"ordinal": 3,
"type_info": "Varchar"
},
{
"name": "pub_id",
"ordinal": 4,
"type_info": "Uuid"
}
],
"nullable": [
false,
false,
false,
false,
false
],
"parameters": {
"Left": [
"Text",
"Text"
]
}
},
"query": "SELECT site_secret, repo_url, branch, hostname, pub_id\n FROM librepages_sites\n WHERE deleted = false\n AND owned_by = (SELECT ID FROM librepages_users WHERE name = $1 )\n AND hostname = $2;\n "
},
"6a557f851d4f47383b864085093beb0954e79779f21b655978f07e285281e0ac": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
}
},
"query": "UPDATE librepages_users set email = $1\n WHERE name = $2"
},
"6db98c6ae90b8eb98ace1a5acfa3c8af2b6ed7d51c6debda12637f5d7b076c15": {
"describe": {
"columns": [
{
"name": "exists",
"ordinal": 0,
"type_info": "Bool"
}
],
"nullable": [
null
],
"parameters": {
"Left": [
"Text"
]
}
},
"query": "SELECT EXISTS (SELECT 1 from librepages_sites WHERE hostname = $1 AND deleted = false)"
},
"77612c85be99e6de2e4a6e3105ebaeb470d6cc57b2999b673a085da41c035f9e": {
"describe": {
"columns": [
{
"name": "time",
"ordinal": 0,
"type_info": "Timestamptz"
},
{
"name": "pub_id",
"ordinal": 1,
"type_info": "Uuid"
}
],
"nullable": [
false,
false
],
"parameters": {
"Left": [
"Text",
"Text"
]
}
},
"query": "SELECT\n time,\n pub_id\n FROM\n librepages_site_deploy_events\n WHERE\n site = (SELECT ID FROM librepages_sites WHERE hostname = $1)\n AND\n event_type = (SELECT ID FROM librepages_deploy_event_type WHERE name = $2)\n AND\n time = (\n SELECT MAX(time) \n FROM\n librepages_site_deploy_events\n WHERE\n site = (SELECT ID FROM librepages_sites WHERE hostname = $1)\n )\n "
},
"8735b654bc261571e6a5908d55a8217474c76bdff7f3cbcc71500a0fe13249db": {
"describe": {
"columns": [
{
"name": "exists",
"ordinal": 0,
"type_info": "Bool"
}
],
"nullable": [
null
],
"parameters": {
"Left": [
"Text"
]
}
},
"query": "SELECT EXISTS (SELECT 1 from librepages_users WHERE email = $1)"
},
"8bf4e01b8c38d035fe6bdbfbe8ad9cb35e3fc2fd11107bae92880d157ed11379": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Text"
]
}
},
"query": "INSERT INTO librepages_forgejo_webhooks\n (forgejo_url , auth_token, forgejo_webhook_secret, owned_by) VALUES ($1, $2, $3, \n (SELECT ID FROM librepages_users WHERE name = $4)\n )"
},
"90907d6cb4ca3b485f7b583584fb5821a950362679d061e490545c76634c211e": {
"describe": {
"columns": [
{
"name": "exists",
"ordinal": 0,
"type_info": "Bool"
}
],
"nullable": [
null
],
"parameters": {
"Left": [
"Text"
]
}
},
"query": "SELECT EXISTS (SELECT 1 from librepages_sites WHERE repo_url = $1)"
},
"924e756de5544cece865a10a7e136ecc6126e3a603947264cc7899387c18c819": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Text",
"Text"
]
}
},
"query": "UPDATE librepages_users set password = $1\n WHERE name = $2"
},
"9710a01bc4c5c5cda2db27d14baca3d7a6ceffa66c7d539da6fda7947c222e71": {
"describe": {
"columns": [
{
"name": "forgejo_url",
"ordinal": 0,
"type_info": "Varchar"
},
{
"name": "auth_token",
"ordinal": 1,
"type_info": "Varchar"
},
{
"name": "forgejo_webhook_secret",
"ordinal": 2,
"type_info": "Varchar"
}
],
"nullable": [
false,
false,
false
],
"parameters": {
"Left": [
"Text"
]
}
},
"query": "SELECT\n forgejo_url, auth_token, forgejo_webhook_secret\n FROM\n librepages_forgejo_webhooks\n WHERE\n owned_by = (SELECT ID FROM librepages_users WHERE name = $1);\n "
},
"a6284ede1dbf340942dd97afb75865ba0a41009a145254117b03002bd9afa588": {
"describe": {
"columns": [
{
"name": "forgejo_url",
"ordinal": 0,
"type_info": "Varchar"
},
{
"name": "auth_token",
"ordinal": 1,
"type_info": "Varchar"
},
{
"name": "forgejo_webhook_secret",
"ordinal": 2,
"type_info": "Varchar"
}
],
"nullable": [
false,
false,
false
],
"parameters": {
"Left": [
"Text"
]
}
},
"query": "SELECT forgejo_url, auth_token, forgejo_webhook_secret\n FROM librepages_forgejo_webhooks\n WHERE auth_token = $1\n "
},
"b48c77db6e663d97df44bf9ec2ee92fd3e02f2dcbcdbd1d491e09fab2da68494": {
"describe": {
"columns": [
{
"name": "name",
"ordinal": 0,
"type_info": "Varchar"
},
{
"name": "password",
"ordinal": 1,
"type_info": "Text"
}
],
"nullable": [
false,
false
],
"parameters": {
"Left": [
"Text"
]
}
},
"query": "SELECT name, password FROM librepages_users WHERE email = ($1)"
},
"b7e51e976a4a80a78df8dbfed1f195af212023d00faee88ab2d09326896bd653": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Text",
"Int4",
"Text"
]
}
},
"query": "INSERT INTO librepages_forgejo_webhook_site_mapping\n (site_id, forgejo_webhook_id) VALUES (\n (SELECT ID FROM librepages_sites WHERE repo_url = $1 AND ID = $2),\n (SELECT ID FROM librepages_forgejo_webhooks WHERE auth_token = $3)\n ) ON CONFLICT (site_id, forgejo_webhook_id) DO NOTHING;"
},
"b8b1b3c5fa205b071f577b2ce9993ddfc7c99ada26aea48aa1c201c8c3c7fcf6": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Text",
"Varchar",
"Uuid",
"Text"
]
}
},
"query": "\n INSERT INTO librepages_sites\n (site_secret, repo_url, branch, hostname, pub_id, owned_by)\n VALUES ($1, $2, $3, $4, $5, ( SELECT ID FROM librepages_users WHERE name = $6 ));\n "
},
"bdd4d2a1b0b97ebf8ed61cfd120b40146fbf3ea9afb5cd0e03c9d29860b6a26b": {
"describe": {
"columns": [
{
"name": "exists",
"ordinal": 0,
"type_info": "Bool"
}
],
"nullable": [
null
],
"parameters": {
"Left": [
"Text"
]
}
},
"query": "SELECT EXISTS (SELECT 1 from librepages_users WHERE name = $1)"
},
"ced69a08729ffb906e8971dbdce6a8d4197bc9bb8ccd7c58b3a88eb7be73fc2e": {
"describe": {
"columns": [
{
"name": "email",
"ordinal": 0,
"type_info": "Varchar"
}
],
"nullable": [
false
],
"parameters": {
"Left": [
"Text"
]
}
},
"query": "SELECT email FROM librepages_users WHERE name = $1"
},
"d2327c1bcb40e18518c2112413a19a9b26eb0f54f83c53e968c9752d70c8dd4e": {
"describe": {
"columns": [
{
"name": "name",
"ordinal": 0,
"type_info": "Varchar"
},
{
"name": "time",
"ordinal": 1,
"type_info": "Timestamptz"
},
{
"name": "pub_id",
"ordinal": 2,
"type_info": "Uuid"
}
],
"nullable": [
false,
false,
false
],
"parameters": {
"Left": [
"Text"
]
}
},
"query": "SELECT\n librepages_deploy_event_type.name,\n librepages_site_deploy_events.time,\n librepages_site_deploy_events.pub_id\n FROM\n librepages_site_deploy_events\n INNER JOIN librepages_deploy_event_type ON\n librepages_deploy_event_type.ID = librepages_site_deploy_events.event_type\n WHERE\n librepages_site_deploy_events.site = (\n SELECT ID FROM librepages_sites WHERE hostname = $1\n );\n "
},
"e4adf1bc9175eeb9d61b495653bb452039cc38818c8792acdc6a1c732b6f4554": {
"describe": {
"columns": [
{
"name": "exists",
"ordinal": 0,
"type_info": "Bool"
}
],
"nullable": [
null
],
"parameters": {
"Left": [
"Text"
]
}
},
"query": "SELECT EXISTS (SELECT 1 from librepages_deploy_event_type WHERE name = $1)"
},
"f651da8f411b7977cb87dd8d4bd5d167661d7ef1d865747e76219453d386d593": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Varchar"
]
}
},
"query": "INSERT INTO librepages_deploy_event_type\n (name) VALUES ($1) ON CONFLICT (name) DO NOTHING;"
},
"faa4170a309f19a4abf1ca3f8dd3c0526945aa00f028ebf8bd7063825d448f5b": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Varchar"
]
}
},
"query": "INSERT INTO librepages_users\n (name , password, email) VALUES ($1, $2, $3)"
}
}

17
src/api/mod.rs Normal file
View File

@ -0,0 +1,17 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
pub mod v1;

144
src/api/v1/account/mod.rs Normal file
View File

@ -0,0 +1,144 @@
/*
* Copyright (C) 2021 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_identity::Identity;
use actix_web::{web, HttpResponse, Responder};
use serde::{Deserialize, Serialize};
use crate::ctx::api::v1::account::*;
use crate::ctx::api::v1::auth::Password;
use crate::errors::*;
use crate::AppCtx;
#[cfg(test)]
pub mod test;
pub use super::auth;
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct AccountCheckPayload {
pub val: String,
}
pub fn services(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(username_exists);
cfg.service(set_username);
cfg.service(email_exists);
cfg.service(set_email);
cfg.service(delete_account);
cfg.service(update_user_password);
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Email {
pub email: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Username {
pub username: String,
}
/// update username
#[actix_web_codegen_const_routes::post(
path = "crate::V1_API_ROUTES.account.update_username",
wrap = "super::get_auth_middleware()"
)]
#[tracing::instrument(name = "Update username", skip(ctx, payload, id))]
async fn set_username(
id: Identity,
payload: web::Json<Username>,
ctx: AppCtx,
) -> ServiceResult<impl Responder> {
let username = id.identity().unwrap();
let new_name = ctx.update_username(&username, &payload.username).await?;
id.forget();
id.remember(new_name);
Ok(HttpResponse::Ok())
}
#[actix_web_codegen_const_routes::post(path = "crate::V1_API_ROUTES.account.username_exists")]
#[tracing::instrument(name = "Check if username exists", skip(ctx, payload))]
async fn username_exists(
payload: web::Json<AccountCheckPayload>,
ctx: AppCtx,
) -> ServiceResult<impl Responder> {
Ok(HttpResponse::Ok().json(ctx.username_exists(&payload.val).await?))
}
#[actix_web_codegen_const_routes::post(path = "crate::V1_API_ROUTES.account.email_exists")]
#[tracing::instrument(name = "Check if email exists", skip(ctx, payload))]
pub async fn email_exists(
payload: web::Json<AccountCheckPayload>,
ctx: AppCtx,
) -> ServiceResult<impl Responder> {
Ok(HttpResponse::Ok().json(ctx.email_exists(&payload.val).await?))
}
/// update email
#[actix_web_codegen_const_routes::post(
path = "crate::V1_API_ROUTES.account.update_email",
wrap = "super::get_auth_middleware()"
)]
#[tracing::instrument(name = "Update email", skip(ctx, payload, id))]
async fn set_email(
id: Identity,
payload: web::Json<Email>,
ctx: AppCtx,
) -> ServiceResult<impl Responder> {
let username = id.identity().unwrap();
ctx.set_email(&username, &payload.email).await?;
Ok(HttpResponse::Ok())
}
#[actix_web_codegen_const_routes::post(
path = "crate::V1_API_ROUTES.account.delete",
wrap = "super::get_auth_middleware()"
)]
#[tracing::instrument(name = "Delete account", skip(ctx, payload, id))]
async fn delete_account(
id: Identity,
payload: web::Json<Password>,
ctx: AppCtx,
) -> ServiceResult<impl Responder> {
let username = id.identity().unwrap();
ctx.delete_user(&username, &payload.password).await?;
id.forget();
Ok(HttpResponse::Ok())
}
#[actix_web_codegen_const_routes::post(
path = "crate::V1_API_ROUTES.account.update_password",
wrap = "super::get_auth_middleware()"
)]
#[tracing::instrument(name = "Update user password", skip(ctx, payload, id))]
async fn update_user_password(
id: Identity,
ctx: AppCtx,
payload: web::Json<ChangePasswordReqest>,
) -> ServiceResult<impl Responder> {
let username = id.identity().unwrap();
let payload = payload.into_inner();
ctx.change_password(&username, &payload).await?;
Ok(HttpResponse::Ok())
}

296
src/api/v1/account/test.rs Normal file
View File

@ -0,0 +1,296 @@
/*
* Copyright (C) 2021 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::sync::Arc;
use actix_web::http::StatusCode;
use actix_web::test;
use super::*;
use crate::api::v1::ROUTES;
use crate::ctx::api::v1::auth::Password;
use crate::ctx::Ctx;
use crate::*;
#[actix_rt::test]
async fn postgrest_account_works() {
let (_, ctx) = crate::tests::get_ctx().await;
uname_email_exists_works(ctx.clone()).await;
email_udpate_password_validation_del_userworks(ctx.clone()).await;
username_update_works(ctx.clone()).await;
update_password_works(ctx.clone()).await;
}
async fn uname_email_exists_works(ctx: Arc<Ctx>) {
const NAME: &str = "testuserexists";
const PASSWORD: &str = "longpasswordasdfa2";
const EMAIL: &str = "testuserexists2@a.com";
let _ = ctx.delete_user(NAME, PASSWORD).await;
let (_, signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await;
let cookies = get_cookie!(signin_resp);
let app = get_app!(ctx).await;
let mut payload = AccountCheckPayload { val: NAME.into() };
let user_exists_resp = test::call_service(
&app,
post_request!(&payload, ROUTES.account.username_exists)
.cookie(cookies.clone())
.to_request(),
)
.await;
assert_eq!(user_exists_resp.status(), StatusCode::OK);
let mut resp: AccountCheckResp = test::read_body_json(user_exists_resp).await;
assert!(resp.exists);
payload.val = PASSWORD.into();
let user_doesnt_exist = test::call_service(
&app,
post_request!(&payload, ROUTES.account.username_exists)
.cookie(cookies.clone())
.to_request(),
)
.await;
assert_eq!(user_doesnt_exist.status(), StatusCode::OK);
resp = test::read_body_json(user_doesnt_exist).await;
assert!(!resp.exists);
let email_doesnt_exist = test::call_service(
&app,
post_request!(&payload, ROUTES.account.email_exists)
.cookie(cookies.clone())
.to_request(),
)
.await;
assert_eq!(email_doesnt_exist.status(), StatusCode::OK);
resp = test::read_body_json(email_doesnt_exist).await;
assert!(!resp.exists);
payload.val = EMAIL.into();
let email_exist = test::call_service(
&app,
post_request!(&payload, ROUTES.account.email_exists)
.cookie(cookies.clone())
.to_request(),
)
.await;
assert_eq!(email_exist.status(), StatusCode::OK);
resp = test::read_body_json(email_exist).await;
assert!(resp.exists);
}
async fn email_udpate_password_validation_del_userworks(ctx: Arc<Ctx>) {
const NAME: &str = "testuser2";
const PASSWORD: &str = "longpassword2";
const EMAIL: &str = "testuser1@a.com2";
const NAME2: &str = "eupdauser";
const EMAIL2: &str = "eupdauser@a.com";
let _ = ctx.delete_user(NAME, PASSWORD).await;
let _ = ctx.delete_user(NAME2, PASSWORD).await;
let _ = ctx.register_and_signin(NAME2, EMAIL2, PASSWORD).await;
let (_creds, signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await;
let cookies = get_cookie!(signin_resp);
let app = get_app!(ctx).await;
// update email
let mut email_payload = Email {
email: EMAIL.into(),
};
let email_update_resp = test::call_service(
&app,
post_request!(&email_payload, ROUTES.account.update_email)
//post_request!(&email_payload, EMAIL_UPDATE)
.cookie(cookies.clone())
.to_request(),
)
.await;
assert_eq!(email_update_resp.status(), StatusCode::OK);
// check duplicate email while duplicate email
email_payload.email = EMAIL2.into();
ctx.bad_post_req_test(
NAME,
PASSWORD,
ROUTES.account.update_email,
&email_payload,
ServiceError::EmailTaken,
)
.await;
// wrong password while deleteing account
let mut payload = Password {
password: NAME.into(),
};
ctx.bad_post_req_test(
NAME,
PASSWORD,
ROUTES.account.delete,
&payload,
ServiceError::WrongPassword,
)
.await;
// delete account
payload.password = PASSWORD.into();
let delete_user_resp = test::call_service(
&app,
post_request!(&payload, ROUTES.account.delete)
.cookie(cookies.clone())
.to_request(),
)
.await;
assert_eq!(delete_user_resp.status(), StatusCode::OK);
// try to delete an account that doesn't exist
let account_not_found_resp = test::call_service(
&app,
post_request!(&payload, ROUTES.account.delete)
.cookie(cookies)
.to_request(),
)
.await;
assert_eq!(account_not_found_resp.status(), StatusCode::NOT_FOUND);
let txt: ErrorToResponse = test::read_body_json(account_not_found_resp).await;
assert_eq!(txt.error, format!("{}", ServiceError::AccountNotFound));
}
async fn username_update_works(ctx: Arc<Ctx>) {
const NAME: &str = "testuserupda";
const EMAIL: &str = "testuserupda@sss.com";
const EMAIL2: &str = "testuserupda2@sss.com";
const PASSWORD: &str = "longpassword2";
const NAME2: &str = "terstusrtds";
const NAME_CHANGE: &str = "terstusrtdsxx";
let _ = futures::join!(
ctx.delete_user(NAME, PASSWORD),
ctx.delete_user(NAME2, PASSWORD),
ctx.delete_user(NAME_CHANGE, PASSWORD)
);
let _ = ctx.register_and_signin(NAME2, EMAIL2, PASSWORD).await;
let (_creds, signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await;
let cookies = get_cookie!(signin_resp);
let app = get_app!(ctx).await;
// update username
let mut username_udpate = Username {
username: NAME_CHANGE.into(),
};
let username_update_resp = test::call_service(
&app,
post_request!(&username_udpate, ROUTES.account.update_username)
.cookie(cookies)
.to_request(),
)
.await;
assert_eq!(username_update_resp.status(), StatusCode::OK);
// check duplicate username with duplicate username
username_udpate.username = NAME2.into();
ctx.bad_post_req_test(
NAME_CHANGE,
PASSWORD,
ROUTES.account.update_username,
&username_udpate,
ServiceError::UsernameTaken,
)
.await;
}
async fn update_password_works(ctx: Arc<Ctx>) {
const NAME: &str = "updatepassuser";
const PASSWORD: &str = "longpassword2";
const EMAIL: &str = "updatepassuser@a.com";
let _ = ctx.delete_user(NAME, PASSWORD).await;
let (_, signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await;
let cookies = get_cookie!(signin_resp);
let app = get_app!(ctx).await;
let new_password = "newpassword";
let update_password = ChangePasswordReqest {
password: PASSWORD.into(),
new_password: new_password.into(),
confirm_new_password: PASSWORD.into(),
};
let res = ctx.change_password(NAME, &update_password).await;
assert!(res.is_err());
assert_eq!(res, Err(ServiceError::PasswordsDontMatch));
let update_password = ChangePasswordReqest {
password: PASSWORD.into(),
new_password: new_password.into(),
confirm_new_password: new_password.into(),
};
assert!(ctx.change_password(NAME, &update_password).await.is_ok());
let update_password = ChangePasswordReqest {
password: new_password.into(),
new_password: new_password.into(),
confirm_new_password: PASSWORD.into(),
};
ctx.bad_post_req_test(
NAME,
new_password,
ROUTES.account.update_password,
&update_password,
ServiceError::PasswordsDontMatch,
)
.await;
let update_password = ChangePasswordReqest {
password: PASSWORD.into(),
new_password: PASSWORD.into(),
confirm_new_password: PASSWORD.into(),
};
ctx.bad_post_req_test(
NAME,
new_password,
ROUTES.account.update_password,
&update_password,
ServiceError::WrongPassword,
)
.await;
let update_password = ChangePasswordReqest {
password: new_password.into(),
new_password: PASSWORD.into(),
confirm_new_password: PASSWORD.into(),
};
let update_password_resp = test::call_service(
&app,
post_request!(&update_password, ROUTES.account.update_password)
.cookie(cookies)
.to_request(),
)
.await;
assert_eq!(update_password_resp.status(), StatusCode::OK);
}

74
src/api/v1/auth.rs Normal file
View File

@ -0,0 +1,74 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::ctx::api::v1::auth::{Login, Register};
use actix_identity::Identity;
use actix_web::http::header;
use actix_web::{web, HttpResponse, Responder};
use super::RedirectQuery;
use crate::errors::*;
use crate::AppCtx;
pub fn services(cfg: &mut web::ServiceConfig) {
cfg.service(register);
cfg.service(login);
cfg.service(signout);
}
#[actix_web_codegen_const_routes::post(path = "crate::V1_API_ROUTES.auth.register")]
#[tracing::instrument(name = "Register new user", skip(ctx, payload))]
async fn register(payload: web::Json<Register>, ctx: AppCtx) -> ServiceResult<impl Responder> {
ctx.register(&payload).await?;
Ok(HttpResponse::Ok())
}
#[actix_web_codegen_const_routes::post(path = "crate::V1_API_ROUTES.auth.login")]
#[tracing::instrument(name = "Login", skip(ctx, payload, id, query))]
async fn login(
id: Identity,
payload: web::Json<Login>,
query: web::Query<RedirectQuery>,
ctx: AppCtx,
) -> ServiceResult<impl Responder> {
let payload = payload.into_inner();
let username = ctx.login(&payload).await?;
id.remember(username);
let query = query.into_inner();
if let Some(redirect_to) = query.redirect_to {
Ok(HttpResponse::Found()
.insert_header((header::LOCATION, redirect_to))
.finish())
} else {
Ok(HttpResponse::Ok().into())
}
}
#[actix_web_codegen_const_routes::get(
path = "crate::V1_API_ROUTES.auth.logout",
wrap = "super::get_auth_middleware()"
)]
#[tracing::instrument(name = "Sign out", skip(id))]
async fn signout(id: Identity) -> impl Responder {
use actix_auth_middleware::GetLoginRoute;
if id.identity().is_some() {
id.forget();
}
HttpResponse::Found()
.append_header((header::LOCATION, crate::V1_API_ROUTES.get_login_route(None)))
.finish()
}

282
src/api/v1/forgejo.rs Normal file
View File

@ -0,0 +1,282 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_identity::Identity;
use actix_web::{web, HttpRequest, HttpResponse, Responder};
use serde::{Deserialize, Serialize};
use tracing::info;
use url::Url;
use super::get_auth_middleware;
use crate::{errors::*, AppCtx};
pub mod routes {
use crate::ctx::Ctx;
pub struct Forgejo {
pub add_webhook: &'static str,
pub view_webhook: &'static str,
pub list_webhooks: &'static str,
pub webhook: &'static str,
}
impl Forgejo {
pub const fn new() -> Self {
Self {
add_webhook: "/api/v1/forgejo/webhook/add",
list_webhooks: "/api/v1/forgejo/webhook/add",
view_webhook: "/api/v1/forgejo/webhook/view/{auth_token}",
webhook: "/api/v1/forgejo/webhook/event/new",
}
}
pub fn get_view(&self, auth_token: &str) -> String {
self.view_webhook.replace("{auth_token}", auth_token)
}
pub fn get_webhook_url(&self, ctx: &Ctx, auth_token: &str) -> String {
format!(
"https://{}{}?auth={auth_token}",
&ctx.settings.server.domain, self.webhook
)
}
}
}
#[derive(Serialize, Deserialize)]
pub struct AddWebhook {
pub forgejo_url: Url,
}
#[actix_web_codegen_const_routes::post(
path = "crate::V1_API_ROUTES.forgejo.add_webhook",
wrap = "get_auth_middleware()"
)]
#[tracing::instrument(name = "Add webhook" skip(id, ctx, payload))]
async fn add_webhook(
ctx: AppCtx,
id: Identity,
payload: web::Json<AddWebhook>,
) -> ServiceResult<impl Responder> {
info!(
"Adding webhook for Forgejo instance: {}",
payload.forgejo_url.as_str()
);
let owner = id.identity().unwrap();
let payload = payload.into_inner();
let hook = ctx.db.new_webhook(payload.forgejo_url, &owner).await?;
Ok(HttpResponse::Ok().json(hook))
}
#[actix_web_codegen_const_routes::get(
path = "crate::V1_API_ROUTES.forgejo.list_webhooks",
wrap = "get_auth_middleware()"
)]
#[tracing::instrument(name = "Delete webhook" skip(id, ctx))]
async fn list_webhooks(ctx: AppCtx, id: Identity) -> ServiceResult<impl Responder> {
let owner = id.identity().unwrap();
info!("Getting all webhooks created by {}", owner);
let hooks = ctx.db.list_all_webhooks_with_owner(&owner).await?;
Ok(HttpResponse::Ok().json(hooks))
}
#[actix_web_codegen_const_routes::get(
path = "crate::V1_API_ROUTES.forgejo.view_webhook",
wrap = "get_auth_middleware()"
)]
#[tracing::instrument(name = "Delete webhook" skip(id, ctx, path))]
async fn view_webhook(
ctx: AppCtx,
id: Identity,
path: web::Path<String>,
) -> ServiceResult<impl Responder> {
let path = path.into_inner();
let owner = id.identity().unwrap();
info!("Gitting webhook webhook for Forgejo instance: {}", path,);
let hook = ctx.db.get_webhook_with_owner(&path, &owner).await?;
Ok(HttpResponse::Ok().json(hook))
}
#[derive(Serialize, Deserialize)]
struct Auth {
auth: String,
}
#[actix_web_codegen_const_routes::post(path = "crate::V1_API_ROUTES.forgejo.webhook")]
#[tracing::instrument(name = "Update ", skip(body, ctx, req, q))]
async fn webhook(
ctx: AppCtx,
body: web::Bytes,
req: HttpRequest,
q: web::Query<Auth>,
) -> ServiceResult<impl Responder> {
ctx.process_webhook(&body, &req, &q.auth).await?;
Ok(HttpResponse::Ok())
}
pub fn services(cfg: &mut web::ServiceConfig) {
cfg.service(add_webhook);
cfg.service(view_webhook);
cfg.service(list_webhooks);
cfg.service(webhook);
}
#[cfg(test)]
mod tests {
use actix_web::{error::ResponseError, http::StatusCode, test};
use hmac::Mac;
use crate::ctx::api::v1::forgejo::{HmacSha256, WebhookPayload};
use crate::db::ForgejoWebhook;
use crate::tests;
use crate::*;
use super::*;
#[actix_rt::test]
async fn test_api_forgejo_webhook() {
const NAME: &str = "apiforgejowebhookuser";
const PASSWORD: &str = "longpasswordasdfa2";
const EMAIL: &str = "apiforgejowebhookuser@a.com";
let (_dir, ctx) = tests::get_ctx().await;
let _ = ctx.delete_user(NAME, PASSWORD).await;
let (_, signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await;
let page = ctx.add_test_site(NAME.into()).await;
let cookies = get_cookie!(signin_resp);
let app = get_app!(ctx).await;
let payload = AddWebhook {
forgejo_url: Url::parse("https://git.batnsense.net").unwrap(),
};
let add_webhook_resp = test::call_service(
&app,
post_request!(&payload, V1_API_ROUTES.forgejo.add_webhook)
.cookie(cookies.clone())
.to_request(),
)
.await;
check_status!(add_webhook_resp, StatusCode::OK);
let response: ForgejoWebhook = actix_web::test::read_body_json(add_webhook_resp).await;
assert_eq!(response.forgejo_url, payload.forgejo_url);
let view_webhook_resp = get_request!(
&app,
&V1_API_ROUTES.forgejo.get_view(&response.auth_token),
cookies.clone()
);
check_status!(view_webhook_resp, StatusCode::OK);
let hook: ForgejoWebhook = actix_web::test::read_body_json(view_webhook_resp).await;
assert_eq!(hook, response);
let list_all_webhooks_resp =
get_request!(&app, V1_API_ROUTES.forgejo.list_webhooks, cookies.clone());
check_status!(list_all_webhooks_resp, StatusCode::OK);
let hooks: Vec<ForgejoWebhook> =
actix_web::test::read_body_json(list_all_webhooks_resp).await;
assert_eq!(vec![hook.clone()], hooks);
let webhook_url = format!("{}?auth={}", V1_API_ROUTES.forgejo.webhook, hook.auth_token);
// test webhook
let mut webhook_payload = WebhookPayload::default();
webhook_payload.reference = format!("refs/origin/{}", page.branch);
webhook_payload.repository.html_url = page.repo;
let body = serde_json::to_string(&webhook_payload).unwrap();
let body = body.as_bytes();
let mut mac = HmacSha256::new_from_slice(hook.forgejo_webhook_secret.as_bytes())
.expect("HMAC can take key of any size");
mac.update(body);
let res = mac.finalize();
let sig = res.into_bytes();
let sig = hex::encode(&sig[..]);
let post_to_webhook_resp = test::call_service(
&app,
post_request!(&webhook_payload, &webhook_url)
.insert_header(("X-Gitea-Delivery", "foobar213randomuuid"))
.insert_header(("X-Gitea-Signature", sig.clone()))
.insert_header(("X-Gitea-Event", "push"))
.cookie(cookies.clone())
.to_request(),
)
.await;
check_status!(post_to_webhook_resp, StatusCode::OK);
// no webhook
let fake_webhook_url = format!(
"{}?auth={}",
V1_API_ROUTES.forgejo.webhook, hook.forgejo_webhook_secret
);
let body = serde_json::to_string(&webhook_payload).unwrap();
let body = body.as_bytes();
let mut mac =
HmacSha256::new_from_slice(b"nosecret").expect("HMAC can take key of any size");
mac.update(body);
let res = mac.finalize();
let fake_sig = res.into_bytes();
let fake_sig = hex::encode(&fake_sig[..]);
let post_to_no_exist_webhook_resp = test::call_service(
&app,
post_request!(&webhook_payload, &fake_webhook_url)
.insert_header(("X-Gitea-Delivery", "foobar213randomuuid"))
.insert_header(("X-Gitea-Signature", fake_sig))
.insert_header(("X-Gitea-Event", "push"))
.cookie(cookies.clone())
.to_request(),
)
.await;
let err = ServiceError::WebhookNotFound;
assert_eq!(post_to_no_exist_webhook_resp.status(), err.status_code());
let resp_err: ErrorToResponse =
actix_web::test::read_body_json(post_to_no_exist_webhook_resp).await;
assert_eq!(resp_err.error, err.to_string());
// no website
let mut webhook_payload = WebhookPayload::default();
webhook_payload.reference = format!("refs/origin/{}", page.branch);
webhook_payload.repository.html_url = "https://no-exist-git.example.org".into();
let body = serde_json::to_string(&webhook_payload).unwrap();
let body = body.as_bytes();
let mut mac = HmacSha256::new_from_slice(hook.forgejo_webhook_secret.as_bytes())
.expect("HMAC can take key of any size");
mac.update(body);
let res = mac.finalize();
let sig = res.into_bytes();
let sig = hex::encode(&sig[..]);
let post_to_no_website_webhook_resp = test::call_service(
&app,
post_request!(&webhook_payload, &webhook_url)
.insert_header(("X-Gitea-Delivery", "foobar213randomuuid"))
.insert_header(("X-Gitea-Signature", sig.clone()))
.insert_header(("X-Gitea-Event", "push"))
.cookie(cookies.clone())
.to_request(),
)
.await;
let err = ServiceError::WebsiteNotFound;
assert_eq!(post_to_no_website_webhook_resp.status(), err.status_code());
let resp_err: ErrorToResponse =
actix_web::test::read_body_json(post_to_no_website_webhook_resp).await;
assert_eq!(resp_err.error, err.to_string());
}
}

View File

@ -42,8 +42,9 @@ pub mod routes {
}
}
/// emmits build details of the bninary
#[my_codegen::get(path = "crate::V1_API_ROUTES.meta.build_details")]
/// emits build details of the binary
#[actix_web_codegen_const_routes::get(path = "crate::V1_API_ROUTES.meta.build_details")]
#[tracing::instrument(name = "Fetch Build Details", skip(ctx))]
async fn build_details(ctx: AppCtx) -> impl Responder {
let build = BuildDetails {
version: VERSION,
@ -53,8 +54,26 @@ async fn build_details(ctx: AppCtx) -> impl Responder {
HttpResponse::Ok().json(build)
}
#[derive(Clone, Debug, Deserialize, Serialize)]
/// Health check return datatype
pub struct Health {
db: bool,
}
/// checks all components of the system
#[actix_web_codegen_const_routes::get(path = "crate::V1_API_ROUTES.meta.health")]
#[tracing::instrument(name = "Fetch health", skip(ctx))]
async fn health(ctx: crate::AppCtx) -> impl Responder {
let res = Health {
db: ctx.db.ping().await,
};
HttpResponse::Ok().json(res)
}
pub fn services(cfg: &mut web::ServiceConfig) {
cfg.service(build_details);
cfg.service(health);
}
#[cfg(test)]
@ -65,11 +84,31 @@ mod tests {
#[actix_rt::test]
async fn build_details_works() {
let (_dir, ctx) = tests::get_data().await;
let (_dir, ctx) = tests::get_ctx().await;
println!("[log] test configuration {:#?}", ctx.settings);
let app = get_app!(ctx).await;
let resp = get_request!(app, V1_API_ROUTES.meta.build_details);
check_status!(resp, StatusCode::OK);
}
#[actix_rt::test]
async fn health_works() {
use actix_web::test;
let (_dir, ctx) = tests::get_ctx().await;
let app = get_app!(ctx).await;
let resp = test::call_service(
&app,
test::TestRequest::get()
.uri(crate::V1_API_ROUTES.meta.health)
.to_request(),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let health_resp: super::Health = test::read_body_json(resp).await;
assert!(health_resp.db);
}
}

48
src/api/v1/mod.rs Normal file
View File

@ -0,0 +1,48 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_auth_middleware::Authentication;
use actix_web::web::ServiceConfig;
use serde::Deserialize;
pub mod account;
pub mod auth;
pub mod forgejo;
pub mod meta;
pub mod pages;
pub mod routes;
pub use routes::ROUTES;
pub fn services(cfg: &mut ServiceConfig) {
auth::services(cfg);
account::services(cfg);
meta::services(cfg);
forgejo::services(cfg);
pages::services(cfg);
}
#[derive(Deserialize)]
pub struct RedirectQuery {
pub redirect_to: Option<String>,
}
pub fn get_auth_middleware() -> Authentication<routes::Routes> {
Authentication::with_identity(ROUTES)
}
#[cfg(test)]
mod tests;

View File

@ -16,7 +16,7 @@
*/
use actix_web::{web, HttpResponse, Responder};
use serde::{Deserialize, Serialize};
use tokio::sync::oneshot;
use uuid::Uuid;
use crate::errors::*;
use crate::page::Page;
@ -44,30 +44,19 @@ pub struct DeployEvent {
pub branch: String,
}
pub fn find_page<'a>(secret: &str, ctx: &'a AppCtx) -> Option<&'a Page> {
for page in ctx.settings.pages.iter() {
if page.secret == secret {
return Some(page);
}
}
None
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct DeployEventResp {
pub id: Uuid,
}
#[my_codegen::post(path = "crate::V1_API_ROUTES.deploy.update")]
#[actix_web_codegen_const_routes::post(path = "crate::V1_API_ROUTES.deploy.update")]
#[tracing::instrument(name = "Update webpages", skip(payload, ctx))]
async fn update(payload: web::Json<DeployEvent>, ctx: AppCtx) -> ServiceResult<impl Responder> {
if let Some(page) = find_page(&payload.secret, &ctx) {
let (tx, rx) = oneshot::channel();
let page = page.clone();
web::block(move || {
tx.send(page.update(&payload.branch)).unwrap();
})
.await
.unwrap();
rx.await.unwrap()?;
Ok(HttpResponse::Ok())
} else {
Err(ServiceError::WebsiteNotFound)
}
let payload = payload.into_inner();
let id = ctx
.update_site(&payload.secret, Some(payload.branch))
.await?;
Ok(HttpResponse::Ok().json(DeployEventResp { id }))
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
@ -98,13 +87,14 @@ impl DeployInfo {
}
}
#[my_codegen::post(path = "crate::V1_API_ROUTES.deploy.info")]
#[actix_web_codegen_const_routes::post(path = "crate::V1_API_ROUTES.deploy.info")]
#[tracing::instrument(name = "Get webpage deploy info", skip(payload, ctx))]
async fn deploy_info(
payload: web::Json<DeploySecret>,
ctx: AppCtx,
) -> ServiceResult<impl Responder> {
if let Some(page) = find_page(&payload.secret, &ctx) {
let resp = DeployInfo::from_page(page)?;
if let Ok(page) = ctx.db.get_site_from_secret(&payload.secret).await {
let resp = DeployInfo::from_page(&Page::from_site(&ctx.settings, page))?;
Ok(HttpResponse::Ok().json(resp))
} else {
Err(ServiceError::WebsiteNotFound)
@ -127,11 +117,15 @@ mod tests {
#[actix_rt::test]
async fn deploy_update_works() {
let (_dir, ctx) = tests::get_data().await;
println!("[log] test configuration {:#?}", ctx.settings);
const NAME: &str = "dplyupdwrkuser";
const PASSWORD: &str = "longpasswordasdfa2";
const EMAIL: &str = "dplyupdwrkuser@a.com";
let (_dir, ctx) = tests::get_ctx().await;
let _ = ctx.delete_user(NAME, PASSWORD).await;
let (_, _signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await;
let page = ctx.add_test_site(NAME.into()).await;
let app = get_app!(ctx).await;
let page = ctx.settings.pages.get(0);
let page = page.unwrap();
let mut payload = DeployEvent {
secret: page.secret.clone(),
@ -144,6 +138,10 @@ mod tests {
)
.await;
check_status!(resp, StatusCode::OK);
let event_id: DeployEventResp = actix_web::test::read_body_json(resp).await;
let update_event = ctx.db.get_event(&page.domain, &event_id.id).await.unwrap();
assert_eq!(&update_event.site, &page.domain);
assert_eq!(update_event.id, event_id.id);
payload.secret = page.branch.clone();
@ -157,10 +155,14 @@ mod tests {
#[actix_rt::test]
async fn deploy_info_works() {
let (_dir, ctx) = tests::get_data().await;
println!("[log] test configuration {:#?}", ctx.settings);
let page = ctx.settings.pages.get(0);
let page = page.unwrap();
const NAME: &str = "dplyinfwrkuser";
const PASSWORD: &str = "longpasswordasdfa2";
const EMAIL: &str = "dplyinfwrkuser@a.com";
let (_dir, ctx) = tests::get_ctx().await;
let _ = ctx.delete_user(NAME, PASSWORD).await;
let (_, _signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await;
let page = ctx.add_test_site(NAME.into()).await;
let app = get_app!(ctx).await;
let mut payload = DeploySecret {

127
src/api/v1/routes.rs Normal file
View File

@ -0,0 +1,127 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
//! V1 API Routes
use actix_auth_middleware::GetLoginRoute;
use crate::serve::routes::Serve;
use super::forgejo::routes::Forgejo;
use super::meta::routes::Meta;
use super::pages::routes::Deploy;
/// constant [Routes](Routes) instance
pub const ROUTES: Routes = Routes::new();
/// Authentication routes
pub struct Auth {
/// logout route
pub logout: &'static str,
/// login route
pub login: &'static str,
/// registration route
pub register: &'static str,
}
impl Auth {
/// create new instance of Authentication route
pub const fn new() -> Auth {
let login = "/api/v1/signin";
let logout = "/api/v1/logout";
let register = "/api/v1/signup";
Auth {
logout,
login,
register,
}
}
}
/// Account management routes
pub struct Account {
/// delete account route
pub delete: &'static str,
/// route to check if an email exists
pub email_exists: &'static str,
/// route to update a user's email
pub update_email: &'static str,
/// route to update password
pub update_password: &'static str,
/// route to check if a username is already registered
pub username_exists: &'static str,
/// route to change username
pub update_username: &'static str,
}
impl Account {
/// create a new instance of [Account][Account] routes
pub const fn new() -> Account {
let delete = "/api/v1/account/delete";
let email_exists = "/api/v1/account/email/exists";
let username_exists = "/api/v1/account/username/exists";
let update_username = "/api/v1/account/username/update";
let update_email = "/api/v1/account/email/update";
let update_password = "/api/v1/account/password/update";
Account {
delete,
email_exists,
update_email,
update_password,
username_exists,
update_username,
}
}
}
/// Top-level routes data structure for V1 AP1
pub struct Routes {
/// Authentication routes
pub auth: Auth,
/// Account routes
pub account: Account,
/// Meta routes
pub meta: Meta,
pub forgejo: Forgejo,
pub deploy: Deploy,
pub serve: Serve,
}
impl Routes {
/// create new instance of Routes
const fn new() -> Routes {
Routes {
auth: Auth::new(),
account: Account::new(),
meta: Meta::new(),
forgejo: Forgejo::new(),
deploy: Deploy::new(),
serve: Serve::new(),
}
}
}
impl GetLoginRoute for Routes {
fn get_login_route(&self, src: Option<&str>) -> String {
if let Some(redirect_to) = src {
format!(
"{}?redirect_to={}",
self.auth.login,
urlencoding::encode(redirect_to)
)
} else {
self.auth.register.to_string()
}
}
}

174
src/api/v1/tests/auth.rs Normal file
View File

@ -0,0 +1,174 @@
/*
* Copyright (C) 2021 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_auth_middleware::GetLoginRoute;
use actix_web::http::{header, StatusCode};
use actix_web::test;
use crate::api::v1::ROUTES;
use crate::ctx::api::v1::auth::{Login, Register};
use crate::ctx::ArcCtx;
use crate::errors::*;
use crate::*;
#[actix_rt::test]
async fn postgrest_auth_works() {
let (_, ctx) = crate::tests::get_ctx().await;
auth_works(ctx.clone()).await;
serverside_password_validation_works(ctx).await;
}
async fn auth_works(ctx: ArcCtx) {
const NAME: &str = "testuserfoo";
const PASSWORD: &str = "longpassword";
const EMAIL: &str = "testuser1foo@a.com";
let _ = ctx.delete_user(NAME, PASSWORD).await;
let app = get_app!(ctx).await;
// 1. Register and signin
let (_, signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await;
let cookies = get_cookie!(signin_resp);
// Sign in with email
ctx.signin_test(EMAIL, PASSWORD).await;
// 2. check if duplicate username is allowed
let mut msg = Register {
username: NAME.into(),
password: PASSWORD.into(),
confirm_password: PASSWORD.into(),
email: EMAIL.into(),
};
msg.username = format!("asdfasd{}", msg.username);
ctx.bad_post_req_test(
NAME,
PASSWORD,
ROUTES.auth.register,
&msg,
ServiceError::EmailTaken,
)
.await;
msg.email = format!("asdfasd{}", msg.email);
msg.username = NAME.into();
ctx.bad_post_req_test(
NAME,
PASSWORD,
ROUTES.auth.register,
&msg,
ServiceError::UsernameTaken,
)
.await;
// 3. sigining in with non-existent user
let mut creds = Login {
login: "nonexistantuser".into(),
password: msg.password.clone(),
};
ctx.bad_post_req_test(
NAME,
PASSWORD,
ROUTES.auth.login,
&creds,
ServiceError::AccountNotFound,
)
.await;
creds.login = "nonexistantuser@example.com".into();
ctx.bad_post_req_test(
NAME,
PASSWORD,
ROUTES.auth.login,
&creds,
ServiceError::AccountNotFound,
)
.await;
// 4. trying to signin with wrong password
creds.login = NAME.into();
creds.password = NAME.into();
ctx.bad_post_req_test(
NAME,
PASSWORD,
ROUTES.auth.login,
&creds,
ServiceError::WrongPassword,
)
.await;
// 5. signout
let signout_resp = test::call_service(
&app,
test::TestRequest::get()
.uri(ROUTES.auth.logout)
.cookie(cookies)
.to_request(),
)
.await;
assert_eq!(signout_resp.status(), StatusCode::FOUND);
let headers = signout_resp.headers();
assert_eq!(
headers.get(header::LOCATION).unwrap(),
&crate::V1_API_ROUTES.get_login_route(None)
);
let creds = Login {
login: NAME.into(),
password: PASSWORD.into(),
};
//6. sigin with redirect URL set
let redirect_to = ROUTES.auth.logout;
let resp = test::call_service(
&app,
post_request!(&creds, &ROUTES.get_login_route(Some(redirect_to))).to_request(),
)
.await;
assert_eq!(resp.status(), StatusCode::FOUND);
let headers = resp.headers();
assert_eq!(headers.get(header::LOCATION).unwrap(), &redirect_to);
}
async fn serverside_password_validation_works(ctx: ArcCtx) {
const NAME: &str = "testuser542";
const PASSWORD: &str = "longpassword2";
const EMAIL: &str = "testuser542@example.com";
let _ = ctx.delete_user(NAME, PASSWORD).await;
let app = get_app!(ctx).await;
// checking to see if server-side password validation (password == password_config)
// works
let register_msg = Register {
username: NAME.into(),
password: PASSWORD.into(),
confirm_password: NAME.into(),
email: EMAIL.into(),
};
let resp = test::call_service(
&app,
post_request!(&register_msg, ROUTES.auth.register).to_request(),
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let txt: ErrorToResponse = test::read_body_json(resp).await;
assert_eq!(txt.error, format!("{}", ServiceError::PasswordsDontMatch));
}

18
src/api/v1/tests/mod.rs Normal file
View File

@ -0,0 +1,18 @@
/*
* Copyright (C) 2021 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
mod auth;
mod protected;

View File

@ -0,0 +1,70 @@
/*
* Copyright (C) 2021 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_web::http::StatusCode;
use actix_web::test;
use crate::ctx::ArcCtx;
//use crate::pages::PAGES;
use crate::*;
use crate::tests::*;
#[actix_rt::test]
async fn postgrest_protected_routes_work() {
let (_, ctx) = get_ctx().await;
protected_routes_work(ctx.clone()).await
}
async fn protected_routes_work(ctx: ArcCtx) {
const NAME: &str = "testuser619";
const PASSWORD: &str = "longpassword2";
const EMAIL: &str = "testuser119@a.com2";
let _post_protected_urls = [
"/api/v1/account/secret/",
"/api/v1/account/email/",
"/api/v1/account/delete",
];
let get_protected_urls = [
V1_API_ROUTES.auth.logout,
// PAGES.auth.logout,
// PAGES.home,
];
let _ = ctx.delete_user(NAME, PASSWORD).await;
let (_, signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await;
let cookies = get_cookie!(signin_resp);
let app = get_app!(ctx).await;
for url in get_protected_urls.iter() {
let resp = get_request!(&app, url);
assert_eq!(resp.status(), StatusCode::FOUND);
let authenticated_resp = get_request!(&app, url, cookies.clone());
println!("{url}");
if url == &V1_API_ROUTES.auth.logout {
// || url == &PAGES.auth.logout {
assert_eq!(authenticated_resp.status(), StatusCode::FOUND);
} else {
assert_eq!(authenticated_resp.status(), StatusCode::OK);
}
}
}

96
src/conductor.rs Normal file
View File

@ -0,0 +1,96 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use reqwest::Client;
use libconductor::EventType;
use libconfig::Config;
use tracing::info;
use crate::errors::ServiceResult;
use crate::{page::Page, settings::Settings};
#[derive(Clone)]
pub struct Conductor {
client: Client,
pub settings: Settings,
}
impl Conductor {
pub fn new(settings: Settings, client: Option<Client>) -> Self {
let client = if let Some(client) = client {
client
} else {
Client::new()
};
Self { client, settings }
}
async fn tx(&self, e: &EventType) -> ServiceResult<()> {
for c in self.settings.conductors.iter() {
info!("Tx event to {}", c.url);
let mut event_url = c.url.clone();
event_url.set_path("/api/v1/events/new");
self.client
.post(event_url)
.basic_auth(&c.username, Some(&c.api_key))
.json(e)
.send()
.await
.unwrap();
}
Ok(())
}
pub async fn new_site(&self, page: Page) -> ServiceResult<()> {
let msg = EventType::NewSite {
hostname: page.domain,
branch: page.branch,
path: page.path,
};
self.tx(&msg).await
}
pub async fn tx_config(&self, config: Config) -> ServiceResult<()> {
self.tx(&EventType::Config { data: config }).await
}
pub async fn delete_site(&self, hostname: String) -> ServiceResult<()> {
self.tx(&EventType::DeleteSite { hostname }).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;
#[actix_rt::test]
pub async fn test_conductor() {
let settings = Settings::new().unwrap();
let c = Conductor::new(settings.clone(), None);
c.delete_site("example.org".into()).await.unwrap();
let page = Page {
secret: "foo".into(),
repo: "foo".into(),
path: "foo".into(),
branch: "foo".into(),
domain: "foo".into(),
pub_id: Uuid::new_v4(),
};
c.new_site(page).await.unwrap();
}
}

17
src/ctx/api/mod.rs Normal file
View File

@ -0,0 +1,17 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
pub mod v1;

136
src/ctx/api/v1/account.rs Normal file
View File

@ -0,0 +1,136 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
//! Account management utility datastructures and methods
use serde::{Deserialize, Serialize};
pub use super::auth;
use crate::ctx::Ctx;
use crate::db;
use crate::errors::*;
#[derive(Clone, Debug, Deserialize, Serialize)]
/// Data structure used in `*_exists` methods
pub struct AccountCheckResp {
/// set to true if the attribute in question exists
pub exists: bool,
}
/// Data structure used to change password of a registered user
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ChangePasswordReqest {
/// current password
pub password: String,
/// new password
pub new_password: String,
/// new password confirmation
pub confirm_new_password: String,
}
impl Ctx {
/// check if email exists on database
pub async fn email_exists(&self, email: &str) -> ServiceResult<AccountCheckResp> {
let resp = AccountCheckResp {
exists: self.db.email_exists(email).await?,
};
Ok(resp)
}
/// update email
pub async fn set_email(&self, username: &str, new_email: &str) -> ServiceResult<()> {
self.creds.email(new_email)?;
let username = self.creds.username(username)?;
let payload = db::UpdateEmail {
username: &username,
new_email,
};
self.db.update_email(&payload).await?;
Ok(())
}
/// check if email exists in database
pub async fn username_exists(&self, username: &str) -> ServiceResult<AccountCheckResp> {
let processed_uname = self.creds.username(username)?;
let resp = AccountCheckResp {
exists: self.db.username_exists(&processed_uname).await?,
};
Ok(resp)
}
/// update username of a registered user
pub async fn update_username(
&self,
current_username: &str,
new_username: &str,
) -> ServiceResult<String> {
let processed_uname = self.creds.username(new_username)?;
self.db
.update_username(current_username, &processed_uname)
.await?;
Ok(processed_uname)
}
// returns Ok(()) upon successful authentication
async fn authenticate(&self, username: &str, password: &str) -> ServiceResult<()> {
use argon2_creds::Config;
let username = self.creds.username(username)?;
let resp = self
.db
.get_password(&db::Login::Username(&username))
.await?;
if Config::verify(&resp.hash, password)? {
Ok(())
} else {
Err(ServiceError::WrongPassword)
}
}
/// delete user
pub async fn delete_user(&self, username: &str, password: &str) -> ServiceResult<()> {
let username = self.creds.username(username)?;
self.authenticate(&username, password).await?;
self.db.delete_user(&username).await?;
Ok(())
}
/// change password
pub async fn change_password(
&self,
username: &str,
payload: &ChangePasswordReqest,
) -> ServiceResult<()> {
if payload.new_password != payload.confirm_new_password {
return Err(ServiceError::PasswordsDontMatch);
}
self.authenticate(username, &payload.password).await?;
let hash = self.creds.password(&payload.new_password)?;
let username = self.creds.username(username)?;
let db_payload = db::NameHash { username, hash };
self.db.update_password(&db_payload).await?;
Ok(())
}
}

104
src/ctx/api/v1/auth.rs Normal file
View File

@ -0,0 +1,104 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
//! Authentication helper methods and data structures
use serde::{Deserialize, Serialize};
use crate::ctx::Ctx;
use crate::db;
use crate::errors::*;
/// Register payload
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Register {
/// username
pub username: String,
/// password
pub password: String,
/// password confirmation: `password` and `confirm_password` must match
pub confirm_password: String,
pub email: String,
}
/// Login payload
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Login {
// login accepts both username and email under "username field"
// TODO update all instances where login is used
/// user identifier: either username or email
/// an email is detected by checkinf for the existence of `@` character
pub login: String,
/// password
pub password: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
/// struct used to represent password
pub struct Password {
/// password
pub password: String,
}
impl Ctx {
/// Log in method. Returns `Ok(())` when user is authenticated and errors when authentication
/// fails
pub async fn login(&self, payload: &Login) -> ServiceResult<String> {
use argon2_creds::Config;
let verify = |stored: &str, received: &str| {
if Config::verify(stored, received)? {
Ok(())
} else {
Err(ServiceError::WrongPassword)
}
};
let creds = if payload.login.contains('@') {
self.db
.get_password(&db::Login::Email(&payload.login))
.await?
} else {
self.db
.get_password(&db::Login::Username(&payload.login))
.await?
};
verify(&creds.hash, &payload.password)?;
Ok(creds.username)
}
/// register new user
pub async fn register(&self, payload: &Register) -> ServiceResult<()> {
if !self.settings.allow_registration {
return Err(ServiceError::ClosedForRegistration);
}
if payload.password != payload.confirm_password {
return Err(ServiceError::PasswordsDontMatch);
}
let username = self.creds.username(&payload.username)?;
let hash = self.creds.password(&payload.password)?;
self.creds.email(&payload.email)?;
let db_payload = db::Register {
username: &username,
hash: &hash,
email: &payload.email,
};
self.db.register(&db_payload).await
}
}

204
src/ctx/api/v1/forgejo.rs Normal file
View File

@ -0,0 +1,204 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_web::web;
use actix_web::HttpRequest;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use tracing::{info, warn};
use url::Url;
use crate::ctx::Ctx;
use crate::errors::ServiceError;
use crate::errors::ServiceResult;
pub type HmacSha256 = Hmac<Sha256>;
#[derive(Serialize, Deserialize, Debug, Default, Clone, Eq, PartialEq)]
pub struct CommitPerson {
pub name: String,
pub email: String,
pub username: String,
}
#[derive(Serialize, Deserialize, Default, Debug, Clone, Eq, PartialEq)]
pub struct Commit {
pub id: String,
pub message: String,
pub url: String,
pub author: CommitPerson,
pub committer: CommitPerson,
pub verification: serde_json::Value,
pub timestamp: String,
pub added: serde_json::Value,
pub removed: serde_json::Value,
pub modified: serde_json::Value,
}
#[derive(Serialize, Deserialize, Debug, Default, Clone, Eq, PartialEq)]
pub struct Person {
pub id: usize,
pub login: String,
pub full_name: String,
pub email: String,
pub avatar_url: String,
pub language: String,
pub is_admin: bool,
pub last_login: String,
pub created: String,
pub restricted: bool,
pub active: bool,
pub prohibit_login: bool,
pub location: String,
pub website: String,
pub description: String,
pub visibility: String,
pub followers_count: usize,
pub following_count: usize,
pub starred_repos_count: usize,
pub username: String,
}
#[derive(Serialize, Deserialize, Debug, Default, Clone, Eq, PartialEq)]
pub struct Permissions {
pub admin: bool,
pub push: bool,
pub pull: bool,
}
#[derive(Serialize, Deserialize, Debug, Default, Clone, Eq, PartialEq)]
pub struct InternalTracker {
pub enable_time_tracker: bool,
pub allow_only_contributors_to_track_time: bool,
pub enable_issue_dependencies: bool,
}
#[derive(Serialize, Deserialize, Debug, Default, Clone, Eq, PartialEq)]
pub struct Repository {
pub id: usize,
pub owner: Person,
pub name: String,
pub full_name: String,
pub description: String,
pub empty: bool,
pub private: bool,
pub fork: bool,
pub template: bool,
pub parent: Option<serde_json::Value>,
pub mirror: bool,
pub size: usize,
pub html_url: String,
pub ssh_url: String,
pub clone_url: String,
pub original_url: String,
pub website: String,
pub stars_count: usize,
pub forks_count: usize,
pub watchers_count: usize,
pub open_issues_count: usize,
pub open_pr_counter: usize,
pub release_counter: usize,
pub default_branch: String,
pub archived: bool,
pub created_at: String,
pub updated_at: String,
pub permissions: Permissions,
pub has_issues: bool,
pub internal_tracker: InternalTracker,
pub has_wiki: bool,
pub has_pull_requests: bool,
pub has_projects: bool,
pub ignore_whitespace_conflicts: bool,
pub allow_merge_commits: bool,
pub allow_rebase: bool,
pub allow_rebase_explicit: bool,
pub allow_squash_merge: bool,
pub default_merge_style: String,
pub avatar_url: String,
pub internal: bool,
pub mirror_interval: String,
pub mirror_updated: String,
pub repo_transfer: Option<serde_json::Value>,
}
#[derive(Serialize, Deserialize, Debug, Default, Clone, Eq, PartialEq)]
pub struct WebhookPayload {
#[serde(rename(serialize = "ref", deserialize = "ref"))]
pub reference: String,
pub before: String,
pub after: String,
pub compare_url: String,
pub repository: Repository,
pub pusher: Person,
pub sender: Person,
}
impl Ctx {
pub async fn process_webhook(
&self,
body: &web::Bytes,
req: &HttpRequest,
auth_token: &str,
) -> ServiceResult<()> {
let headers = req.headers();
let _uuid = headers.get("X-Gitea-Delivery").unwrap();
let sig = headers.get("X-Gitea-Signature").unwrap();
let sig = hex::decode(sig).unwrap();
let event_type = headers.get("X-Gitea-Event").unwrap();
let payload: WebhookPayload = serde_json::from_slice(body).unwrap();
let hook = self.db.get_webhook(auth_token).await?;
for url in [
&payload.repository.html_url,
&payload.repository.ssh_url,
&payload.repository.clone_url,
] {
if self.db.site_with_repository_exists(url).await? {
let mut mac = HmacSha256::new_from_slice(hook.forgejo_webhook_secret.as_bytes())?;
mac.update(body);
mac.verify_slice(&sig[..])?;
let site = self.db.get_site_from_repo_url(url).await?;
self.db
.webhook_link_site(auth_token, &Url::parse(&site.repo_url)?)
.await?;
if payload.reference.contains(&site.branch) {
info!(
"[webhook][forgejo/gitea] received update {:?} from {url} repository on deployed branch",
event_type
);
self.update_site(&site.site_secret, Some(site.branch))
.await?;
} else {
info!(
"[webhook][forgejo/gitea] received update {:?} from {url} repository on non-deployed branch {}",
event_type,
payload.reference
);
}
return Ok(());
}
}
warn!(
"[webhook][forgejo/gitea] stray update from {} repository",
payload.repository.html_url
);
Err(ServiceError::WebsiteNotFound)
}
}

View File

@ -14,17 +14,10 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::sync::Arc;
pub mod account;
pub mod auth;
pub mod forgejo;
pub mod pages;
use crate::settings::Settings;
#[derive(Clone)]
pub struct Ctx {
pub settings: Settings,
}
impl Ctx {
pub fn new(settings: Settings) -> Arc<Self> {
Arc::new(Self { settings })
}
}
#[cfg(test)]
mod tests;

123
src/ctx/api/v1/pages.rs Normal file
View File

@ -0,0 +1,123 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_web::web;
use serde::{Deserialize, Serialize};
use tokio::fs;
use tokio::sync::oneshot;
use uuid::Uuid;
use crate::ctx::Ctx;
use crate::db;
use crate::db::Site;
use crate::errors::*;
use crate::page::Page;
use crate::page_config;
use crate::settings::Settings;
use crate::subdomains::get_random_subdomain;
use crate::utils::get_random;
use crate::utils::get_website_path;
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
/// Data required to add site
pub struct AddSite {
pub repo_url: String,
pub branch: String,
pub owner: String,
}
impl AddSite {
fn to_site(self, s: &Settings) -> Site {
let site_secret = get_random(32);
let hostname = get_random_subdomain(s);
let pub_id = Uuid::new_v4();
Site {
site_secret,
repo_url: self.repo_url,
branch: self.branch,
hostname,
owner: self.owner,
pub_id,
}
}
}
impl Ctx {
pub async fn add_site(&self, site: AddSite) -> ServiceResult<Page> {
let db_site = site.to_site(&self.settings);
self.db.add_site(&db_site).await?;
let page = Page::from_site(&self.settings, db_site);
page.update(&page.branch)?;
self.db
.log_event(&page.domain, &db::EVENT_TYPE_CREATE)
.await?;
self.conductor.new_site(page.clone()).await?;
if let Some(config) = page_config::load(&page.path, &page.branch) {
self.conductor.tx_config(config).await?;
unimplemented!("Parse and store custom domains in DB");
}
Ok(page)
}
pub async fn update_site(&self, secret: &str, branch: Option<String>) -> ServiceResult<Uuid> {
if let Ok(db_site) = self.db.get_site_from_secret(secret).await {
let page = Page::from_site(&self.settings, db_site);
let (tx, rx) = oneshot::channel();
{
let page = page.clone();
web::block(move || {
if let Some(branch) = branch {
tx.send(page.update(&branch)).unwrap();
} else {
tx.send(page.update(&page.branch)).unwrap();
}
})
.await
.unwrap();
}
rx.await.unwrap()?;
if let Some(config) = page_config::load(&page.path, &page.branch) {
self.conductor.tx_config(config).await?;
unimplemented!("Parse and store custom domains in DB");
}
self.db
.log_event(&page.domain, &db::EVENT_TYPE_UPDATE)
.await
} else {
Err(ServiceError::WebsiteNotFound)
}
}
pub async fn delete_site(&self, owner: String, site_id: Uuid) -> ServiceResult<()> {
if let Ok(db_site) = self.db.get_site_from_pub_id(site_id, owner).await {
let path = get_website_path(&self.settings, &db_site.hostname);
self.db
.log_event(&db_site.hostname, &db::EVENT_TYPE_DELETE)
.await?;
fs::remove_dir_all(&path).await?;
self.db
.delete_site(&db_site.owner, &db_site.hostname)
.await?;
self.conductor.delete_site(db_site.hostname).await?;
Ok(())
} else {
Err(ServiceError::WebsiteNotFound)
}
}
}

View File

@ -0,0 +1,227 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::api::v1::account::{Email, Username};
use crate::ctx::api::v1::account::ChangePasswordReqest;
use crate::ctx::api::v1::auth::Password;
use crate::ctx::api::v1::auth::Register;
use crate::ctx::ArcCtx;
use crate::errors::*;
use crate::*;
#[actix_rt::test]
async fn postgrest_account_works() {
let (_dir, ctx) = crate::tests::get_ctx().await;
uname_email_exists_works(ctx.clone()).await;
email_udpate_password_validation_del_userworks(ctx.clone()).await;
username_update_works(ctx).await;
}
async fn uname_email_exists_works(ctx: ArcCtx) {
const NAME: &str = "testuserexistsfoo";
const NAME2: &str = "testuserexists22";
const NAME3: &str = "testuserexists32";
const PASSWORD: &str = "longpassword2";
const EMAIL: &str = "accotestsuser22@a.com";
const EMAIL2: &str = "accotestsuser222@a.com";
const EMAIL3: &str = "accotestsuser322@a.com";
let _ = ctx.db.delete_user(NAME).await;
let _ = ctx.db.delete_user(PASSWORD).await;
let _ = ctx.db.delete_user(NAME2).await;
let _ = ctx.db.delete_user(NAME3).await;
// check username exists for non existent account
println!("{:?}", ctx.username_exists(NAME).await);
assert!(!ctx.username_exists(NAME).await.unwrap().exists);
// check username email for non existent account
assert!(!ctx.email_exists(EMAIL).await.unwrap().exists);
let mut register_payload = Register {
username: NAME.into(),
password: PASSWORD.into(),
confirm_password: PASSWORD.into(),
email: EMAIL.into(),
};
ctx.register(&register_payload).await.unwrap();
register_payload.username = NAME2.into();
register_payload.email = EMAIL2.into();
ctx.register(&register_payload).await.unwrap();
// check username exists
assert!(ctx.username_exists(NAME).await.unwrap().exists);
assert!(ctx.username_exists(NAME2).await.unwrap().exists);
// check email exists
assert!(ctx.email_exists(EMAIL).await.unwrap().exists);
// update username
ctx.update_username(NAME2, NAME3).await.unwrap();
assert!(!ctx.username_exists(NAME2).await.unwrap().exists);
assert!(ctx.username_exists(NAME3).await.unwrap().exists);
assert!(matches!(
ctx.update_username(NAME3, NAME).await.err(),
Some(ServiceError::UsernameTaken)
));
// update email
assert_eq!(
ctx.set_email(NAME, EMAIL2).await.err(),
Some(ServiceError::EmailTaken)
);
ctx.set_email(NAME, EMAIL3).await.unwrap();
// change password
let mut change_password_req = ChangePasswordReqest {
password: PASSWORD.into(),
new_password: NAME.into(),
confirm_new_password: PASSWORD.into(),
};
assert_eq!(
ctx.change_password(NAME, &change_password_req).await.err(),
Some(ServiceError::PasswordsDontMatch)
);
change_password_req.confirm_new_password = NAME.into();
ctx.change_password(NAME, &change_password_req)
.await
.unwrap();
}
async fn email_udpate_password_validation_del_userworks(ctx: ArcCtx) {
const NAME: &str = "testuser32sd2";
const PASSWORD: &str = "longpassword2";
const EMAIL: &str = "testuser12232@a.com2";
const NAME2: &str = "eupdauser22";
const EMAIL2: &str = "eupdauser22@a.com";
let _ = ctx.delete_user(NAME, PASSWORD).await;
let _ = ctx.delete_user(NAME2, PASSWORD).await;
let _ = ctx.register_and_signin(NAME2, EMAIL2, PASSWORD).await;
let (_creds, signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await;
let cookies = get_cookie!(signin_resp);
let app = get_app!(ctx).await;
// update email
let mut email_payload = Email {
email: EMAIL.into(),
};
let email_update_resp = actix_web::test::call_service(
&app,
post_request!(&email_payload, crate::V1_API_ROUTES.account.update_email)
//post_request!(&email_payload, EMAIL_UPDATE)
.cookie(cookies.clone())
.to_request(),
)
.await;
assert_eq!(email_update_resp.status(), StatusCode::OK);
// check duplicate email while duplicate email
email_payload.email = EMAIL2.into();
ctx.bad_post_req_test(
NAME,
PASSWORD,
crate::V1_API_ROUTES.account.update_email,
&email_payload,
ServiceError::EmailTaken,
)
.await;
// wrong password while deleting account
let mut payload = Password {
password: NAME.into(),
};
ctx.bad_post_req_test(
NAME,
PASSWORD,
V1_API_ROUTES.account.delete,
&payload,
ServiceError::WrongPassword,
)
.await;
// delete account
payload.password = PASSWORD.into();
let delete_user_resp = actix_web::test::call_service(
&app,
post_request!(&payload, crate::V1_API_ROUTES.account.delete)
.cookie(cookies.clone())
.to_request(),
)
.await;
assert_eq!(delete_user_resp.status(), StatusCode::OK);
// try to delete an account that doesn't exist
let account_not_found_resp = actix_web::test::call_service(
&app,
post_request!(&payload, crate::V1_API_ROUTES.account.delete)
.cookie(cookies)
.to_request(),
)
.await;
assert_eq!(account_not_found_resp.status(), StatusCode::NOT_FOUND);
let txt: ErrorToResponse = actix_web::test::read_body_json(account_not_found_resp).await;
assert_eq!(txt.error, format!("{}", ServiceError::AccountNotFound));
}
async fn username_update_works(ctx: ArcCtx) {
const NAME: &str = "testuse23423rupda";
const EMAIL: &str = "testu23423serupda@sss.com";
const EMAIL2: &str = "testu234serupda2@sss.com";
const PASSWORD: &str = "longpassword2";
const NAME2: &str = "terstusrt23423ds";
const NAME_CHANGE: &str = "terstu234234srtdsxx";
let _ = futures::join!(
ctx.delete_user(NAME, PASSWORD),
ctx.delete_user(NAME2, PASSWORD),
ctx.delete_user(NAME_CHANGE, PASSWORD)
);
let _ = ctx.register_and_signin(NAME2, EMAIL2, PASSWORD).await;
let (_creds, signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await;
let cookies = get_cookie!(signin_resp);
let app = get_app!(ctx).await;
// update username
let mut username_udpate = Username {
username: NAME_CHANGE.into(),
};
let username_update_resp = actix_web::test::call_service(
&app,
post_request!(
&username_udpate,
crate::V1_API_ROUTES.account.update_username
)
.cookie(cookies)
.to_request(),
)
.await;
assert_eq!(username_update_resp.status(), StatusCode::OK);
// check duplicate username with duplicate username
username_udpate.username = NAME2.into();
ctx.bad_post_req_test(
NAME_CHANGE,
PASSWORD,
V1_API_ROUTES.account.update_username,
&username_udpate,
ServiceError::UsernameTaken,
)
.await;
}

View File

@ -0,0 +1,104 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::sync::Arc;
//use crate::api::v1::auth::{Login, Register};
use crate::ctx::api::v1::auth::{Login, Register};
use crate::ctx::Ctx;
use crate::errors::*;
#[actix_rt::test]
async fn postgrest_auth_works() {
let (_dir, ctx) = crate::tests::get_ctx().await;
auth_works(ctx).await;
}
async fn auth_works(ctx: Arc<Ctx>) {
const NAME: &str = "testuser";
const PASSWORD: &str = "longpassword";
const EMAIL: &str = "testuser1@a.com";
let _ = ctx.delete_user(NAME, PASSWORD).await;
// 1. Register with email == None
let mut register_payload = Register {
username: NAME.into(),
password: PASSWORD.into(),
confirm_password: PASSWORD.into(),
email: EMAIL.into(),
};
// registration: passwords don't match
register_payload.confirm_password = NAME.into();
assert!(matches!(
ctx.register(&register_payload).await.err(),
Some(ServiceError::PasswordsDontMatch)
));
register_payload.confirm_password = PASSWORD.into();
ctx.register(&register_payload).await.unwrap();
// check if duplicate username is allowed
assert!(matches!(
ctx.register(&register_payload).await.err(),
Some(ServiceError::UsernameTaken)
));
// check if duplicate email is allowed
let name = format!("{}dupemail", NAME);
register_payload.username = name;
assert!(matches!(
ctx.register(&register_payload).await.err(),
Some(ServiceError::EmailTaken)
));
// Sign in with email
let mut creds = Login {
login: EMAIL.into(),
password: PASSWORD.into(),
};
ctx.login(&creds).await.unwrap();
// signin with username
creds.login = NAME.into();
ctx.login(&creds).await.unwrap();
// sigining in with non-existent username
creds.login = "nonexistantuser".into();
assert!(matches!(
ctx.login(&creds).await.err(),
Some(ServiceError::AccountNotFound)
));
// sigining in with non-existent email
creds.login = "nonexistantuser@example.com".into();
assert!(matches!(
ctx.login(&creds).await.err(),
Some(ServiceError::AccountNotFound)
));
// sign in with incorrect password
creds.login = NAME.into();
creds.password = NAME.into();
assert!(matches!(
ctx.login(&creds).await.err(),
Some(ServiceError::WrongPassword)
));
// delete user
ctx.delete_user(NAME, PASSWORD).await.unwrap();
}

View File

@ -0,0 +1,2 @@
mod accounts;
mod auth;

79
src/ctx/mod.rs Normal file
View File

@ -0,0 +1,79 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::sync::Arc;
use std::thread;
use crate::db::*;
use crate::settings::Settings;
use argon2_creds::{Config as ArgonConfig, ConfigBuilder as ArgonConfigBuilder, PasswordPolicy};
use reqwest::Client;
use tracing::info;
pub mod api;
use crate::conductor::Conductor;
pub type ArcCtx = Arc<Ctx>;
#[derive(Clone)]
pub struct Ctx {
pub settings: Settings,
pub db: Database,
pub conductor: Conductor,
/// credential-procession policy
pub creds: ArgonConfig,
client: Client,
}
impl Ctx {
/// Get credential-processing policy
pub fn get_creds() -> ArgonConfig {
ArgonConfigBuilder::default()
.username_case_mapped(true)
.profanity(true)
.blacklist(true)
.password_policy(PasswordPolicy::default())
.build()
.unwrap()
}
pub async fn new(settings: Settings) -> Arc<Self> {
let creds = Self::get_creds();
let c = creds.clone();
let client = Client::default();
let conductor = Conductor::new(settings.clone(), Some(client.clone()));
#[allow(unused_variables)]
let init = thread::spawn(move || {
info!("Initializing credential manager");
c.init();
info!("Initialized credential manager");
});
let db = get_db(&settings).await;
#[cfg(not(debug_assertions))]
init.join();
Arc::new(Self {
settings,
client,
db,
creds,
conductor,
})
}
}

1299
src/db.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -14,7 +14,7 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
//! represents all the ways a trait can fail using this crate
//! Represents all the ways a trait can fail using this crate
use std::convert::From;
use std::io::Error as FSErrorInner;
use std::sync::Arc;
@ -24,9 +24,12 @@ use actix_web::{
http::{header, StatusCode},
HttpResponse, HttpResponseBuilder,
};
use argon2_creds::errors::CredsError;
use config::ConfigError as ConfigErrorInner;
use derive_more::{Display, Error};
use git2::Error as GitError;
use hmac::digest::InvalidLength;
use hmac::digest::MacError;
use serde::{Deserialize, Serialize};
use url::ParseError;
@ -85,6 +88,10 @@ pub enum ServiceError {
/// website not found
WebsiteNotFound,
#[display(fmt = "File not found")]
/// File not found
FileNotFound,
/// when the a path configured for a page is already taken
#[display(
fmt = "Path already used for another website. lhs: {:?} rhs: {:?}",
@ -126,6 +133,71 @@ pub enum ServiceError {
#[display(fmt = "Branch {} not found", _0)]
BranchNotFound(#[error(not(source))] String),
/// Username is taken
#[display(fmt = "Username is taken")]
UsernameTaken,
/// Email is taken
#[display(fmt = "Email is taken")]
EmailTaken,
/// Account not found
#[display(fmt = "Account not found")]
AccountNotFound,
#[display(
fmt = "This server is is closed for registration. Contact admin if this is unexpecter"
)]
/// registration failure, server is is closed for registration
ClosedForRegistration,
#[display(fmt = "The value you entered for email is not an email")] //405j
/// The value you entered for email is not an email"
NotAnEmail,
#[display(fmt = "Wrong password")]
/// wrong password
WrongPassword,
/// when the value passed contains profanity
#[display(fmt = "Can't allow profanity in usernames")]
ProfanityError,
/// when the value passed contains blacklisted words
/// see [blacklist](https://github.com/shuttlecraft/The-Big-Username-Blacklist)
#[display(fmt = "Username contains blacklisted words")]
BlacklistError,
/// when the value passed contains characters not present
/// in [UsernameCaseMapped](https://tools.ietf.org/html/rfc8265#page-7)
/// profile
#[display(fmt = "username_case_mapped violation")]
UsernameCaseMappedError,
#[display(fmt = "Passsword too short")]
/// password too short
PasswordTooShort,
#[display(fmt = "password too long")]
/// password too long
PasswordTooLong,
#[display(fmt = "Passwords don't match")]
/// passwords don't match
PasswordsDontMatch,
/// Webhook not found
#[display(fmt = "Webhook not found")]
WebhookNotFound,
}
impl From<InvalidLength> for ServiceError {
#[cfg(not(tarpaulin_include))]
fn from(_: InvalidLength) -> ServiceError {
ServiceError::InternalServerError
}
}
impl From<MacError> for ServiceError {
#[cfg(not(tarpaulin_include))]
fn from(_: MacError) -> ServiceError {
ServiceError::WebhookNotFound
}
}
impl From<ParseError> for ServiceError {
@ -184,6 +256,38 @@ impl ResponseError for ServiceError {
ServiceError::BadRequest(_) => StatusCode::BAD_REQUEST,
ServiceError::GitError(_) => StatusCode::BAD_REQUEST,
ServiceError::BranchNotFound(_) => StatusCode::CONFLICT,
ServiceError::EmailTaken => StatusCode::BAD_REQUEST,
ServiceError::UsernameTaken => StatusCode::BAD_REQUEST,
ServiceError::AccountNotFound => StatusCode::NOT_FOUND,
ServiceError::FileNotFound => StatusCode::NOT_FOUND,
ServiceError::ProfanityError => StatusCode::BAD_REQUEST, //BADREQUEST,
ServiceError::BlacklistError => StatusCode::BAD_REQUEST, //BADREQUEST,
ServiceError::UsernameCaseMappedError => StatusCode::BAD_REQUEST, //BADREQUEST,
ServiceError::PasswordTooShort => StatusCode::BAD_REQUEST, //BADREQUEST,
ServiceError::PasswordTooLong => StatusCode::BAD_REQUEST, //BADREQUEST,
ServiceError::PasswordsDontMatch => StatusCode::BAD_REQUEST, //BADREQUEST,
ServiceError::ClosedForRegistration => StatusCode::FORBIDDEN, //FORBIDDEN,
ServiceError::NotAnEmail => StatusCode::BAD_REQUEST, //BADREQUEST,
ServiceError::WrongPassword => StatusCode::UNAUTHORIZED, //UNAUTHORIZED,
ServiceError::WebhookNotFound => StatusCode::NOT_FOUND, //NOT FOUND,
}
}
}
impl From<CredsError> for ServiceError {
#[cfg(not(tarpaulin_include))]
fn from(e: CredsError) -> ServiceError {
match e {
CredsError::UsernameCaseMappedError => ServiceError::UsernameCaseMappedError,
CredsError::ProfainityError => ServiceError::ProfanityError,
CredsError::BlacklistError => ServiceError::BlacklistError,
CredsError::NotAnEmail => ServiceError::NotAnEmail,
CredsError::Argon2Error(_) => ServiceError::InternalServerError,
CredsError::PasswordTooLong => ServiceError::PasswordTooLong,
CredsError::PasswordTooShort => ServiceError::PasswordTooShort,
}
}
}

View File

@ -15,6 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::path::Path;
use std::path::PathBuf;
use git2::*;
use mime_guess::MimeGuess;
@ -113,7 +114,7 @@ impl ContentType {
/// For example, a read request for "foo bar.md" will fail even if that file is present
/// in the repository. However, it will succeed if the output of [escape_spaces] is
/// used in the request.
pub fn read_file(repo_path: &str, path: &str) -> ServiceResult<FileInfo> {
pub fn read_file(repo_path: &PathBuf, path: &str) -> ServiceResult<FileInfo> {
let repo = git2::Repository::open(repo_path).unwrap();
let head = repo.head().unwrap();
let tree = head.peel_to_tree().unwrap();
@ -121,7 +122,7 @@ pub fn read_file(repo_path: &str, path: &str) -> ServiceResult<FileInfo> {
}
pub fn read_preview_file(
repo_path: &str,
repo_path: &PathBuf,
preview_name: &str,
path: &str,
) -> ServiceResult<FileInfo> {
@ -172,8 +173,6 @@ fn read_file_inner(
}
let inner = |repo: &git2::Repository, tree: &git2::Tree| -> ServiceResult<FileInfo> {
// let head = repo.head().unwrap();
// let tree = head.peel_to_tree().unwrap();
let mut path = path;
if path == "/" {
let content = get_index_file(tree.id(), repo);
@ -186,8 +185,14 @@ fn read_file_inner(
if path.starts_with('/') {
path = path.trim_start_matches('/');
}
let entry = tree.get_path(Path::new(path)).unwrap();
//FileType::Dir(items)
fn file_not_found(e: git2::Error) -> ServiceError {
if e.code() == ErrorCode::NotFound && e.class() == ErrorClass::Tree {
return ServiceError::FileNotFound;
}
e.into()
}
let entry = tree.get_path(Path::new(path)).map_err(file_not_found)?;
let mode: GitFileMode = entry.clone().into();
if let Some(name) = entry.name() {
@ -211,17 +216,17 @@ fn read_file_inner(
}
};
//let repo = git2::Repository::open(repo_path).unwrap();
inner(repo, tree)
}
#[cfg(test)]
pub mod tests {
use super::*;
use mktemp::Temp;
const FILE_CONTENT: &str = "foobar";
fn write_file_util(path: &str) {
pub fn write_file_util(repo_path: &str, file_name: &str, content: Option<&str>) {
// TODO change updated in DB
let inner = |repo: &mut Repository| -> ServiceResult<()> {
let mut tree_builder = match repo.head() {
@ -232,10 +237,14 @@ pub mod tests {
let odb = repo.odb().unwrap();
let obj = odb
.write(ObjectType::Blob, FILE_CONTENT.as_bytes())
.unwrap();
tree_builder.insert("README.txt", obj, 0o100644).unwrap();
let content = if content.is_some() {
content.as_ref().unwrap()
} else {
FILE_CONTENT
};
let obj = odb.write(ObjectType::Blob, content.as_bytes()).unwrap();
tree_builder.insert(file_name, obj, 0o100644).unwrap();
let tree_hash = tree_builder.write().unwrap();
let author = Signature::now("librepages", "admin@librepages.org").unwrap();
let committer = Signature::now("librepages", "admin@librepages.org").unwrap();
@ -267,26 +276,34 @@ pub mod tests {
Ok(())
};
if Repository::open(path).is_err() {
let _ = Repository::init(path);
if Repository::open(repo_path).is_err() {
let _ = Repository::init(repo_path);
}
let mut repo = Repository::open(path).unwrap();
let mut repo = Repository::open(repo_path).unwrap();
let _ = inner(&mut repo);
}
#[test]
fn test_git_write_read_works() {
const PATH: &str = "/tmp/librepges/test_git_write_read_works";
const FILENAME: &str = "README.txt";
write_file_util(PATH);
let resp = read_file(PATH, "README.txt").unwrap();
assert_eq!(resp.filename, "README.txt");
let tmp_dir = Temp::new_dir().unwrap();
let path = tmp_dir.to_str().unwrap();
write_file_util(path, FILENAME, None);
let resp = read_file(&Path::new(path).into(), FILENAME).unwrap();
assert_eq!(resp.filename, FILENAME);
assert_eq!(resp.content.bytes(), FILE_CONTENT.as_bytes());
assert_eq!(resp.mime.first().unwrap(), "text/plain");
let resp = read_preview_file(PATH, "master", "README.txt").unwrap();
assert_eq!(resp.filename, "README.txt");
let resp = read_preview_file(&Path::new(path).into(), "master", FILENAME).unwrap();
assert_eq!(resp.filename, FILENAME);
assert_eq!(resp.content.bytes(), FILE_CONTENT.as_bytes());
assert_eq!(resp.mime.first().unwrap(), "text/plain");
assert_eq!(
read_preview_file(&Path::new(path).into(), "master", "file-does-not-exist.txt"),
Err(ServiceError::FileNotFound)
);
}
}

View File

@ -15,28 +15,37 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::env;
use std::sync::Arc;
use actix_identity::{CookieIdentityPolicy, IdentityService};
use actix_web::{
error::InternalError, http::StatusCode, middleware as actix_middleware, web::Data as WebData,
web::JsonConfig, App, HttpServer,
};
use log::info;
use clap::{Parser, Subcommand};
use static_assets::FileMap;
use tracing::info;
use tracing_actix_web::TracingLogger;
mod api;
mod conductor;
mod ctx;
mod deploy;
mod db;
mod errors;
mod git;
mod meta;
mod page;
mod page_config;
mod pages;
mod preview;
mod routes;
mod serve;
mod settings;
mod static_assets;
mod subdomains;
#[cfg(test)]
mod tests;
mod utils;
pub use routes::ROUTES as V1_API_ROUTES;
pub use crate::api::v1::ROUTES as V1_API_ROUTES;
use ctx::Ctx;
pub use settings::Settings;
pub const CACHE_AGE: u32 = 604800;
@ -47,36 +56,69 @@ pub const PKG_NAME: &str = env!("CARGO_PKG_NAME");
pub const PKG_DESCRIPTION: &str = env!("CARGO_PKG_DESCRIPTION");
pub const PKG_HOMEPAGE: &str = env!("CARGO_PKG_HOMEPAGE");
pub type AppCtx = WebData<Arc<ctx::Ctx>>;
pub type AppCtx = WebData<ctx::ArcCtx>;
lazy_static::lazy_static! {
pub static ref FILES: FileMap = FileMap::new();
}
#[derive(Parser)]
#[clap(author, version, about, long_about = None)]
struct Cli {
#[clap(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// run database migrations
Migrate,
/// run server
Serve,
}
#[cfg(not(tarpaulin_include))]
#[actix_web::main]
#[cfg(not(tarpaulin_include))]
async fn main() -> std::io::Result<()> {
{
const LOG_VAR: &str = "RUST_LOG";
if env::var(LOG_VAR).is_err() {
env::set_var("RUST_LOG", "info");
}
if env::var("RUST_LOG").is_err() {
env::set_var("RUST_LOG", "info");
}
let settings = Settings::new().unwrap();
let ctx = WebData::new(ctx::Ctx::new(settings.clone()));
pretty_env_logger::init();
let cli = Cli::parse();
info!(
"{}: {}.\nFor more information, see: {}\nBuild info:\nVersion: {} commit: {}",
PKG_NAME, PKG_DESCRIPTION, PKG_HOMEPAGE, VERSION, GIT_COMMIT_HASH
);
info!("Starting server on: http://{}", settings.server.get_ip());
let settings = Settings::new().unwrap();
match &cli.command {
Commands::Migrate => db::get_db(&settings).await.migrate().await.unwrap(),
Commands::Serve => {
let ctx = Ctx::new(settings.clone()).await;
let ctx = actix_web::web::Data::new(ctx);
settings.init();
serve(settings, ctx).await.unwrap();
}
}
Ok(())
}
async fn serve(settings: Settings, ctx: AppCtx) -> std::io::Result<()> {
let ip = settings.server.get_ip();
let workers = settings.server.workers.unwrap_or_else(num_cpus::get);
info!("Starting server on: http://{}", ip);
HttpServer::new(move || {
App::new()
.wrap(actix_middleware::Logger::default())
.wrap(TracingLogger::default())
.wrap(actix_middleware::Compress::default())
.app_data(ctx.clone())
.app_data(get_json_err())
.wrap(get_identity_service(&(settings.clone())))
.wrap(
actix_middleware::DefaultHeaders::new()
.add(("Permissions-Policy", "interest-cohort=()")),
@ -86,8 +128,8 @@ async fn main() -> std::io::Result<()> {
))
.configure(services)
})
.workers(settings.server.workers.unwrap_or_else(num_cpus::get))
.bind(settings.server.get_ip())
.workers(workers)
.bind(ip)
.unwrap()
.run()
.await
@ -101,6 +143,23 @@ pub fn get_json_err() -> JsonConfig {
})
}
pub fn services(cfg: &mut actix_web::web::ServiceConfig) {
routes::services(cfg);
#[cfg(not(tarpaulin_include))]
pub fn get_identity_service(settings: &Settings) -> IdentityService<CookieIdentityPolicy> {
let cookie_secret = &settings.server.cookie_secret;
IdentityService::new(
CookieIdentityPolicy::new(cookie_secret.as_bytes())
.path("/")
.name("Authorization")
//TODO change cookie age
.max_age_secs(216000)
.domain(&settings.server.domain)
.secure(false),
)
}
pub fn services(cfg: &mut actix_web::web::ServiceConfig) {
crate::api::v1::services(cfg);
crate::pages::services(cfg);
crate::static_assets::services(cfg);
crate::serve::services(cfg);
}

View File

@ -14,28 +14,49 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use git2::{build::CheckoutBuilder, BranchType, Direction, Oid, Remote, Repository};
#[cfg(not(test))]
use log::info;
#[cfg(test)]
use std::println as info;
#[cfg(test)]
use std::println as error;
#[cfg(test)]
use std::println as debug;
use git2::{build::CheckoutBuilder, BranchType, Direction, Oid, Remote, Repository};
use serde::Deserialize;
use serde::Serialize;
#[cfg(not(test))]
use tracing::{debug, error, info};
use uuid::Uuid;
use crate::db::Site;
use crate::errors::*;
use crate::settings::Settings;
use crate::utils::get_website_path;
#[derive(Debug, Clone, Eq, PartialEq, Deserialize)]
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct Page {
pub secret: String,
pub repo: String,
pub path: String,
pub branch: String,
pub domain: String,
pub pub_id: Uuid,
}
impl Page {
pub fn from_site(settings: &Settings, s: Site) -> Self {
Self {
secret: s.site_secret,
repo: s.repo_url,
path: get_website_path(settings, &s.hostname)
.to_str()
.unwrap()
.to_owned(),
domain: s.hostname,
branch: s.branch,
pub_id: s.pub_id,
}
}
pub fn open_repo(&self) -> ServiceResult<Repository> {
Ok(Repository::open(&self.path)?)
}
@ -79,7 +100,7 @@ impl Page {
branch: &str,
) -> ServiceResult<git2::AnnotatedCommit<'a>> {
let mut remote = repo.find_remote("origin")?;
log::info!("Fetching {} for repo", remote.name().unwrap());
info!("Fetching {} for repo", remote.name().unwrap());
remote.fetch(&[branch], None, None)?;
let fetch_head = repo.find_reference("FETCH_HEAD")?;
Ok(repo.reference_to_annotated_commit(&fetch_head)?)
@ -96,20 +117,19 @@ impl Page {
// 2. Do the appropriate merge
if analysis.0.is_fast_forward() {
//log::debug!("Doing a fast forward");
log::debug!("Doing a fast forward");
debug!("Doing a fast forward");
// do a fast forward
let refname = format!("refs/heads/{}", branch);
match repo.find_reference(&refname) {
Ok(mut r) => {
log::debug!("fast forwarding");
debug!("fast forwarding");
Self::fast_forward(repo, &mut r, &fetch_commit).unwrap();
}
Err(_) => {
// The branch doesn't exist so just set the reference to the
// commit directly. Usually this is because you are pulling
// into an empty repository.
log::error!("Error in find ref");
error!("Error in find ref");
repo.reference(
&refname,
fetch_commit.id(),
@ -135,7 +155,7 @@ impl Page {
.unwrap();
Self::normal_merge(repo, &head_commit, &fetch_commit).unwrap();
} else {
log::info!("Nothing to do...");
info!("Nothing to do...");
}
Ok(())
}
@ -162,7 +182,7 @@ impl Page {
) -> Result<(), git2::Error> {
let local_tree = repo.find_commit(local.id())?.tree().unwrap();
let remote_tree = repo.find_commit(remote.id())?.tree().unwrap();
println!("{} {}", local.id(), remote.id());
debug!("{} {}", local.id(), remote.id());
let ancestor = repo
.find_commit(repo.merge_base(local.id(), remote.id()).unwrap())
.unwrap()
@ -173,7 +193,7 @@ impl Page {
.unwrap();
if idx.has_conflicts() {
log::debug!("Merge conflicts detected...");
debug!("Merge conflicts detected...");
repo.checkout_index(Some(&mut idx), None)?;
return Ok(());
}
@ -207,7 +227,7 @@ impl Page {
None => String::from_utf8_lossy(lb.name_bytes()).to_string(),
};
let msg = format!("Fast-Forward: Setting {} to id: {}", name, rc.id());
log::debug!("{}", msg);
debug!("{}", msg);
lb.set_target(rc.id(), &msg)?;
repo.set_head(&name)?;
repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))?;
@ -248,16 +268,19 @@ mod tests {
use git2::Repository;
use mktemp::Temp;
use crate::tests;
#[actix_rt::test]
async fn pages_works() {
let tmp_dir = Temp::new_dir().unwrap();
assert!(tmp_dir.exists(), "tmp directory successully created");
let mut page = Page {
secret: String::default(),
repo: "https://github.com/mcaptcha/website".to_owned(),
repo: tests::REPO_URL.into(),
path: tmp_dir.to_str().unwrap().to_string(),
branch: "gh-pages".to_string(),
branch: tests::BRANCH.to_string(),
domain: "mcaptcha.org".into(),
pub_id: Uuid::new_v4(),
};
assert!(

165
src/page_config.rs Normal file
View File

@ -0,0 +1,165 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::path::Path;
use libconfig::Config;
use serde::{Deserialize, Serialize};
use crate::git::{ContentType, GitFileMode};
#[derive(Deserialize, Debug, Serialize, PartialEq, Eq)]
struct Policy<'a> {
rel_path: &'a str,
format: SupportedFormat,
}
impl<'a> Policy<'a> {
const fn new(rel_path: &'a str, format: SupportedFormat) -> Self {
Self { rel_path, format }
}
}
#[derive(Deserialize, Debug, Serialize, PartialEq, Eq)]
enum SupportedFormat {
Json,
Yaml,
Toml,
}
pub fn load<P: AsRef<Path>>(repo_path: &P, branch: &str) -> Option<Config> {
const POLICIES: [Policy; 2] = [
Policy::new("librepages.toml", SupportedFormat::Toml),
Policy::new("librepages.json", SupportedFormat::Json),
];
if let Some(policy) = discover(repo_path, branch, &POLICIES) {
// let path = p.repo.as_ref().join(policy.rel_path);
//let contents = fs::read_to_string(path).await.unwrap();
let file =
crate::git::read_preview_file(&repo_path.as_ref().into(), branch, policy.rel_path)
.unwrap();
if let ContentType::Text(contents) = file.content {
let res = match policy.format {
SupportedFormat::Json => load_json(&contents),
SupportedFormat::Yaml => load_yaml(&contents),
SupportedFormat::Toml => load_toml(&contents),
};
return Some(res);
};
}
None
}
fn discover<'a, P: AsRef<Path>>(
repo_path: &P,
branch: &str,
policies: &'a [Policy<'a>],
) -> Option<&'a Policy<'a>> {
let repo = git2::Repository::open(repo_path).unwrap();
let branch = repo.find_branch(branch, git2::BranchType::Local).unwrap();
// let tree = head.peel_to_tree().unwrap();
let branch = branch.into_reference();
let tree = branch.peel_to_tree().unwrap();
for p in policies.iter() {
let file_exists = tree.iter().any(|x| {
if let Some(name) = x.name() {
if policies.iter().any(|p| p.rel_path == name) {
let mode: GitFileMode = x.into();
matches!(mode, GitFileMode::Executable | GitFileMode::Regular)
} else {
false
}
} else {
false
}
});
if file_exists {
return Some(p);
}
}
None
}
fn load_toml(c: &str) -> Config {
toml::from_str(c).unwrap()
}
fn load_yaml(c: &str) -> Config {
serde_yaml::from_str(c).unwrap()
}
fn load_json(c: &str) -> Config {
serde_json::from_str(c).unwrap()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::tests::write_file_util;
use mktemp::Temp;
use libconfig::*;
#[actix_rt::test]
async fn page_config_test() {
let tmp_dir = Temp::new_dir().unwrap();
let repo_path = tmp_dir.join("page_config_test");
let content = std::fs::read_to_string(
&Path::new("./tests/cases/contains-everything/toml/librepages.toml")
.canonicalize()
.unwrap(),
)
.unwrap();
write_file_util(
repo_path.to_str().unwrap(),
"librepages.toml",
Some(&content),
);
let config = load(&repo_path, "master").unwrap();
assert!(config.forms.as_ref().unwrap().enable);
assert!(config.image_compression.as_ref().unwrap().enable);
assert_eq!(config.source.production_branch, "librepages");
assert_eq!(config.source.staging.as_ref().unwrap(), "beta");
assert_eq!(
config.redirects.as_ref().unwrap(),
&vec![
Redirects {
from: "/from1".into(),
to: "/to1".into()
},
Redirects {
from: "/from2".into(),
to: "/to2".into()
},
]
);
assert_eq!(
config.domains.as_ref().unwrap(),
&vec!["example.org".to_string(), "example.com".to_string(),]
);
}
}

121
src/pages/auth/login.rs Normal file
View File

@ -0,0 +1,121 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::cell::RefCell;
use actix_identity::Identity;
use actix_web::http::header::ContentType;
use tera::Context;
use crate::api::v1::RedirectQuery;
use crate::ctx::api::v1::auth::Login as LoginPayload;
use crate::pages::errors::*;
use crate::settings::Settings;
use crate::AppCtx;
pub use super::*;
pub struct Login {
ctx: RefCell<Context>,
}
pub const LOGIN: TemplateFile = TemplateFile::new("login", "pages/auth/login.html");
impl CtxError for Login {
fn with_error(&self, e: &ReadableError) -> String {
self.ctx.borrow_mut().insert(ERROR_KEY, e);
self.render()
}
}
impl Login {
pub fn new(settings: &Settings, payload: Option<&LoginPayload>) -> Self {
let ctx = RefCell::new(context(settings));
if let Some(payload) = payload {
ctx.borrow_mut().insert(PAYLOAD_KEY, payload);
}
Self { ctx }
}
pub fn render(&self) -> String {
TEMPLATES.render(LOGIN.name, &self.ctx.borrow()).unwrap()
}
pub fn page(s: &Settings) -> String {
let p = Self::new(s, None);
p.render()
}
}
#[actix_web_codegen_const_routes::get(path = "PAGES.auth.login")]
#[tracing::instrument(name = "Serve login page", skip(ctx))]
pub async fn get_login(ctx: AppCtx) -> impl Responder {
let login = Login::page(&ctx.settings);
let html = ContentType::html();
HttpResponse::Ok().content_type(html).body(login)
}
pub fn services(cfg: &mut web::ServiceConfig) {
cfg.service(get_login);
cfg.service(login_submit);
}
#[actix_web_codegen_const_routes::post(path = "PAGES.auth.login")]
#[tracing::instrument(name = "Web UI Login", skip(id, payload, query, ctx))]
pub async fn login_submit(
id: Identity,
payload: web::Form<LoginPayload>,
query: web::Query<RedirectQuery>,
ctx: AppCtx,
) -> PageResult<impl Responder, Login> {
let username = ctx
.login(&payload)
.await
.map_err(|e| PageError::new(Login::new(&ctx.settings, Some(&payload)), e))?;
id.remember(username);
let query = query.into_inner();
if let Some(redirect_to) = query.redirect_to {
Ok(HttpResponse::Found()
.insert_header((http::header::LOCATION, redirect_to))
.finish())
} else {
Ok(HttpResponse::Found()
.insert_header((http::header::LOCATION, PAGES.dash.home))
.finish())
}
}
#[cfg(test)]
mod tests {
use super::Login;
use super::LoginPayload;
use crate::errors::*;
use crate::pages::errors::*;
use crate::settings::Settings;
#[test]
fn register_page_renders() {
let settings = Settings::new().unwrap();
Login::page(&settings);
let payload = LoginPayload {
login: "foo".into(),
password: "foo".into(),
};
let page = Login::new(&settings, Some(&payload));
page.with_error(&ReadableError::new(&ServiceError::WrongPassword));
page.render();
}
}

162
src/pages/auth/mod.rs Normal file
View File

@ -0,0 +1,162 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_identity::Identity;
use actix_web::*;
pub use super::{context, Footer, TemplateFile, PAGES, PAYLOAD_KEY, TEMPLATES};
pub mod login;
pub mod register;
#[cfg(test)]
mod test;
pub const AUTH_BASE: TemplateFile = TemplateFile::new("authbase", "pages/auth/base.html");
pub fn register_templates(t: &mut tera::Tera) {
for template in [AUTH_BASE, login::LOGIN, register::REGISTER].iter() {
template.register(t).expect(template.name);
}
}
pub fn services(cfg: &mut web::ServiceConfig) {
cfg.service(signout);
register::services(cfg);
login::services(cfg);
}
#[actix_web_codegen_const_routes::get(
path = "PAGES.auth.logout",
wrap = "super::get_auth_middleware()"
)]
#[tracing::instrument(name = "Sign out", skip(id))]
async fn signout(id: Identity) -> impl Responder {
use actix_auth_middleware::GetLoginRoute;
if id.identity().is_some() {
id.forget();
}
HttpResponse::Found()
.append_header((http::header::LOCATION, PAGES.get_login_route(None)))
.finish()
}
//#[post(path = "PAGES.auth.login")]
//pub async fn login_submit(
// id: Identity,
// payload: web::Form<runners::Login>,
// data: AppData,
//) -> PageResult<impl Responder> {
// let payload = payload.into_inner();
// match runners::login_runner(&payload, &data).await {
// Ok(username) => {
// id.remember(username);
// Ok(HttpResponse::Found()
// .insert_header((header::LOCATION, PAGES.home))
// .finish())
// }
// Err(e) => {
// let status = e.status_code();
// let heading = status.canonical_reason().unwrap_or("Error");
//
// Ok(HttpResponseBuilder::new(status)
// .content_type("text/html; charset=utf-8")
// .body(
// IndexPage::new(heading, &format!("{}", e))
// .render_once()
// .unwrap(),
// ))
// }
// }
//}
//
//#[cfg(test)]
//mod tests {
// use actix_web::test;
//
// use super::*;
//
// use crate::api::v1::auth::runners::{Login, Register};
// use crate::data::Data;
// use crate::tests::*;
// use crate::*;
// use actix_web::http::StatusCode;
//
// #[actix_rt::test]
// async fn auth_form_works() {
// let data = Data::new().await;
// const NAME: &str = "testuserform";
// const PASSWORD: &str = "longpassword";
//
// let app = get_app!(data).await;
//
// delete_user(NAME, &data).await;
//
// // 1. Register with email == None
// let msg = Register {
// username: NAME.into(),
// password: PASSWORD.into(),
// confirm_password: PASSWORD.into(),
// email: None,
// };
// let resp = test::call_service(
// &app,
// post_request!(&msg, V1_API_ROUTES.auth.register).to_request(),
// )
// .await;
// assert_eq!(resp.status(), StatusCode::OK);
//
// // correct form login
// let msg = Login {
// login: NAME.into(),
// password: PASSWORD.into(),
// };
//
// let resp = test::call_service(
// &app,
// post_request!(&msg, PAGES.auth.login, FORM).to_request(),
// )
// .await;
// assert_eq!(resp.status(), StatusCode::FOUND);
// let headers = resp.headers();
// assert_eq!(headers.get(header::LOCATION).unwrap(), PAGES.home,);
//
// // incorrect form login
// let msg = Login {
// login: NAME.into(),
// password: NAME.into(),
// };
// let resp = test::call_service(
// &app,
// post_request!(&msg, PAGES.auth.login, FORM).to_request(),
// )
// .await;
// assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
//
// // non-existent form login
// let msg = Login {
// login: PASSWORD.into(),
// password: PASSWORD.into(),
// };
// let resp = test::call_service(
// &app,
// post_request!(&msg, PAGES.auth.login, FORM).to_request(),
// )
// .await;
// assert_eq!(resp.status(), StatusCode::NOT_FOUND);
// }
//}
//

109
src/pages/auth/register.rs Normal file
View File

@ -0,0 +1,109 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_web::http::header::ContentType;
use std::cell::RefCell;
use tera::Context;
use crate::ctx::api::v1::auth::Register as RegisterPayload;
use crate::pages::errors::*;
use crate::settings::Settings;
use crate::AppCtx;
pub use super::*;
pub const REGISTER: TemplateFile = TemplateFile::new("register", "pages/auth/register.html");
pub struct Register {
ctx: RefCell<Context>,
}
impl CtxError for Register {
fn with_error(&self, e: &ReadableError) -> String {
self.ctx.borrow_mut().insert(ERROR_KEY, e);
self.render()
}
}
impl Register {
fn new(settings: &Settings, payload: Option<&RegisterPayload>) -> Self {
let ctx = RefCell::new(context(settings));
if let Some(payload) = payload {
ctx.borrow_mut().insert(PAYLOAD_KEY, payload);
}
Self { ctx }
}
pub fn render(&self) -> String {
TEMPLATES.render(REGISTER.name, &self.ctx.borrow()).unwrap()
}
pub fn page(s: &Settings) -> String {
let p = Self::new(s, None);
p.render()
}
}
#[actix_web_codegen_const_routes::get(path = "PAGES.auth.register")]
#[tracing::instrument(name = "Serve registration page", skip(ctx))]
pub async fn get_register(ctx: AppCtx) -> impl Responder {
let login = Register::page(&ctx.settings);
let html = ContentType::html();
HttpResponse::Ok().content_type(html).body(login)
}
pub fn services(cfg: &mut web::ServiceConfig) {
cfg.service(get_register);
cfg.service(register_submit);
}
#[actix_web_codegen_const_routes::post(path = "PAGES.auth.register")]
#[tracing::instrument(name = "Process web UI registration", skip(ctx))]
pub async fn register_submit(
payload: web::Form<RegisterPayload>,
ctx: AppCtx,
) -> PageResult<impl Responder, Register> {
ctx.register(&payload)
.await
.map_err(|e| PageError::new(Register::new(&ctx.settings, Some(&payload)), e))?;
Ok(HttpResponse::Found()
.insert_header((http::header::LOCATION, PAGES.auth.login))
.finish())
}
#[cfg(test)]
mod tests {
use super::Register;
use super::RegisterPayload;
use crate::errors::*;
use crate::pages::errors::*;
use crate::settings::Settings;
#[test]
fn register_page_renders() {
let settings = Settings::new().unwrap();
Register::page(&settings);
let payload = RegisterPayload {
username: "foo".into(),
password: "foo".into(),
confirm_password: "foo".into(),
email: "foo".into(),
};
let page = Register::new(&settings, Some(&payload));
page.with_error(&ReadableError::new(&ServiceError::WrongPassword));
page.render();
}
}

144
src/pages/auth/test.rs Normal file
View File

@ -0,0 +1,144 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_auth_middleware::GetLoginRoute;
use actix_web::http::header;
use actix_web::http::StatusCode;
use actix_web::test;
use super::*;
use crate::ctx::api::v1::auth::{Login, Register};
use crate::ctx::ArcCtx;
use crate::errors::*;
use crate::tests::*;
use crate::*;
#[actix_rt::test]
async fn postgrest_pages_auth_works() {
let (_, ctx) = get_ctx().await;
auth_works(ctx.clone()).await;
serverside_password_validation_works(ctx.clone()).await;
}
async fn auth_works(ctx: ArcCtx) {
const NAME: &str = "testuserform";
const EMAIL: &str = "testuserform@foo.com";
const PASSWORD: &str = "longpassword";
let _ = ctx.delete_user(NAME, PASSWORD).await;
let app = get_app!(ctx).await;
// 1. Register with email
let msg = Register {
username: NAME.into(),
password: PASSWORD.into(),
confirm_password: PASSWORD.into(),
email: EMAIL.into(),
};
let resp = test::call_service(
&app,
post_request!(&msg, PAGES.auth.register, FORM).to_request(),
)
.await;
assert_eq!(resp.status(), StatusCode::FOUND);
let headers = resp.headers();
assert_eq!(headers.get(header::LOCATION).unwrap(), PAGES.auth.login);
// sign in
let msg = Login {
login: NAME.into(),
password: PASSWORD.into(),
};
let resp = test::call_service(
&app,
post_request!(&msg, PAGES.auth.login, FORM).to_request(),
)
.await;
assert_eq!(resp.status(), StatusCode::FOUND);
let headers = resp.headers();
assert_eq!(headers.get(header::LOCATION).unwrap(), PAGES.dash.home);
let cookies = get_cookie!(resp);
// redirect after signin
let redirect = "/foo/bar/nonexistantuser";
let url = PAGES.get_login_route(Some(redirect));
let resp = test::call_service(&app, post_request!(&msg, &url, FORM).to_request()).await;
assert_eq!(resp.status(), StatusCode::FOUND);
let headers = resp.headers();
assert_eq!(headers.get(header::LOCATION).unwrap(), &redirect);
// wrong password signin
let msg = Login {
login: NAME.into(),
password: NAME.into(),
};
let resp = test::call_service(
&app,
post_request!(&msg, PAGES.auth.login, FORM).to_request(),
)
.await;
assert_eq!(resp.status(), ServiceError::WrongPassword.status_code());
// signout
println!("{}", PAGES.auth.logout);
let signout_resp = test::call_service(
&app,
test::TestRequest::get()
.uri(PAGES.auth.logout)
.cookie(cookies)
.to_request(),
)
.await;
assert_eq!(signout_resp.status(), StatusCode::FOUND);
let headers = signout_resp.headers();
assert_eq!(
headers.get(header::LOCATION).unwrap(),
&PAGES.get_login_route(None)
);
let _ = ctx.delete_user(NAME, PASSWORD).await;
}
async fn serverside_password_validation_works(ctx: ArcCtx) {
const NAME: &str = "pagetestuser542";
const EMAIL: &str = "pagetestuser542@foo.com";
const PASSWORD: &str = "longpassword2";
let _ = ctx.delete_user(NAME, PASSWORD).await;
let app = get_app!(ctx).await;
// checking to see if server-side password validation (password == password_config)
// works
let register_msg = Register {
username: NAME.into(),
password: PASSWORD.into(),
confirm_password: NAME.into(),
email: EMAIL.into(),
};
let resp = test::call_service(
&app,
post_request!(&register_msg, PAGES.auth.register, FORM).to_request(),
)
.await;
assert_eq!(
resp.status(),
ServiceError::PasswordsDontMatch.status_code()
);
}

View File

@ -0,0 +1,193 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::cell::RefCell;
use actix_identity::Identity;
use actix_web::http::header::ContentType;
use tera::Context;
use tracing::info;
use super::get_auth_middleware;
use crate::api::v1::forgejo::AddWebhook;
use crate::pages::errors::*;
use crate::settings::Settings;
use crate::AppCtx;
pub use super::*;
pub const DASH_FORGEJO_WEBHOOK_ADD: TemplateFile =
TemplateFile::new("dash_forgejo_webhook_add", "pages/dash/forgejo/add.html");
pub struct Add {
ctx: RefCell<Context>,
}
impl CtxError for Add {
fn with_error(&self, e: &ReadableError) -> String {
self.ctx.borrow_mut().insert(ERROR_KEY, e);
self.render()
}
}
impl Add {
pub fn new(settings: &Settings) -> Self {
let ctx = RefCell::new(context(settings));
Self { ctx }
}
pub fn render(&self) -> String {
TEMPLATES
.render(DASH_FORGEJO_WEBHOOK_ADD.name, &self.ctx.borrow())
.unwrap()
}
}
#[actix_web_codegen_const_routes::get(
path = "PAGES.dash.forgejo_webhook.add",
wrap = "get_auth_middleware()"
)]
#[tracing::instrument(name = "Dashboard add forgejo webhook webpage", skip(ctx))]
pub async fn get_add_forgejo_webhook(ctx: AppCtx) -> PageResult<impl Responder, Add> {
let add = Add::new(&ctx.settings).render();
let html = ContentType::html();
Ok(HttpResponse::Ok().content_type(html).body(add))
}
#[actix_web_codegen_const_routes::post(
path = "PAGES.dash.forgejo_webhook.add",
wrap = "get_auth_middleware()"
)]
#[tracing::instrument(
name = "Post Dashboard add Forgejo webhook webpage",
skip(ctx, id, payload)
)]
pub async fn post_add_forgejo_webhook(
ctx: AppCtx,
id: Identity,
payload: web::Form<AddWebhook>,
) -> PageResult<impl Responder, Add> {
let owner = id.identity().unwrap();
let payload = payload.into_inner();
info!(
"Adding webhook for Forgejo instance: {}",
payload.forgejo_url.as_str()
);
let hook = ctx
.db
.new_webhook(payload.forgejo_url, &owner)
.await
.map_err(|e| PageError::new(Add::new(&ctx.settings), e))?;
Ok(HttpResponse::Found()
.append_header((
http::header::LOCATION,
PAGES.dash.forgejo_webhook.get_view(&hook.auth_token),
))
.finish())
}
pub fn services(cfg: &mut web::ServiceConfig) {
cfg.service(get_add_forgejo_webhook);
cfg.service(post_add_forgejo_webhook);
}
#[cfg(test)]
mod tests {
use actix_web::http::StatusCode;
use actix_web::test;
use url::Url;
use crate::api::v1::forgejo::AddWebhook;
use crate::ctx::ArcCtx;
use crate::tests;
use crate::*;
use super::PAGES;
#[actix_rt::test]
async fn postgres_dashboadr_add_forgejo_webhook_works() {
let (_, ctx) = tests::get_ctx().await;
dashboadr_add_forgejo_webhook_works(ctx.clone()).await;
}
async fn dashboadr_add_forgejo_webhook_works(ctx: ArcCtx) {
const NAME: &str = "testdashwebhookforgejoadduser";
const EMAIL: &str = "testdashwebhookforgejoadduser@foo.com";
const PASSWORD: &str = "longpassword";
let _ = ctx.delete_user(NAME, PASSWORD).await;
let (_, signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await;
let cookies = get_cookie!(signin_resp);
let app = get_app!(ctx.clone()).await;
let resp = get_request!(&app, PAGES.dash.forgejo_webhook.add, cookies.clone());
assert_eq!(resp.status(), StatusCode::OK);
let res = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap();
assert!(res.contains("Add Forgejo Webhook"));
let payload = AddWebhook {
forgejo_url: Url::parse("https://git.batsense.net").unwrap(),
};
let add_webhook = test::call_service(
&app,
post_request!(&payload, PAGES.dash.forgejo_webhook.add, FORM)
.cookie(cookies.clone())
.to_request(),
)
.await;
assert_eq!(add_webhook.status(), StatusCode::FOUND);
let mut hooks = ctx.db.list_all_webhooks_with_owner(NAME).await.unwrap();
let hook = hooks.pop().unwrap();
// let mut event = ctx.db.list(&site.hostname).await.unwrap();
// let event = event.pop().unwrap();
let headers = add_webhook.headers();
let view_webhook_url = PAGES.dash.forgejo_webhook.get_view(&hook.auth_token);
assert_eq!(
headers.get(actix_web::http::header::LOCATION).unwrap(),
&view_webhook_url
);
// list webhooks
let resp = get_request!(&app, PAGES.dash.forgejo_webhook.list, cookies.clone());
assert_eq!(resp.status(), StatusCode::OK);
let res = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap();
assert!(res.contains(hook.forgejo_url.as_str()));
// view webhook
let resp = get_request!(&app, &view_webhook_url, cookies.clone());
assert_eq!(resp.status(), StatusCode::OK);
let res = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap();
assert!(res.contains("****"));
assert!(res.contains(
&crate::V1_API_ROUTES
.forgejo
.get_webhook_url(&ctx, &hook.auth_token)
));
let show_forgejo_webhook_secret =
format!("{view_webhook_url}?show_forgejo_webhook_secret=true");
let resp = get_request!(&app, &show_forgejo_webhook_secret, cookies.clone());
assert_eq!(resp.status(), StatusCode::OK);
let res = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap();
assert!(res.contains(&hook.forgejo_webhook_secret));
}
}

View File

@ -0,0 +1,93 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::cell::RefCell;
use actix_identity::Identity;
use actix_web::http::header::ContentType;
use tera::Context;
use super::get_auth_middleware;
use crate::errors::ServiceResult;
use crate::pages::errors::*;
use crate::settings::Settings;
use crate::AppCtx;
pub use super::*;
pub const DASH_FORGEJO_WEBHOOK_LIST: TemplateFile =
TemplateFile::new("dash_forgejo_webhook_list", "pages/dash/forgejo/list.html");
pub struct List {
ctx: RefCell<Context>,
}
impl CtxError for List {
fn with_error(&self, e: &ReadableError) -> String {
self.ctx.borrow_mut().insert(ERROR_KEY, e);
self.render()
}
}
impl List {
pub fn new(settings: &Settings, hooks: Option<&[TemplateForgejoWebhook]>) -> Self {
let ctx = RefCell::new(context(settings));
if let Some(hooks) = hooks {
ctx.borrow_mut().insert(PAYLOAD_KEY, hooks);
}
Self { ctx }
}
pub fn render(&self) -> String {
TEMPLATES
.render(DASH_FORGEJO_WEBHOOK_LIST.name, &self.ctx.borrow())
.unwrap()
}
}
async fn get_webhook_data(
ctx: &AppCtx,
id: &Identity,
) -> ServiceResult<Vec<TemplateForgejoWebhook>> {
let db_hooks = ctx
.db
.list_all_webhooks_with_owner(&id.identity().unwrap())
.await?;
let mut hooks = Vec::with_capacity(db_hooks.len());
for hook in db_hooks {
hooks.push(TemplateForgejoWebhook::new(ctx, hook));
}
Ok(hooks)
}
#[actix_web_codegen_const_routes::get(
path = "PAGES.dash.forgejo_webhook.list",
wrap = "get_auth_middleware()"
)]
#[tracing::instrument(name = "List all Forgejo webhooks", skip(ctx, id))]
pub async fn list_hooks(ctx: AppCtx, id: Identity) -> PageResult<impl Responder, List> {
let sites = get_webhook_data(&ctx, &id)
.await
.map_err(|e| PageError::new(List::new(&ctx.settings, None), e))?;
let home = List::new(&ctx.settings, Some(&sites)).render();
let html = ContentType::html();
Ok(HttpResponse::Ok().content_type(html).body(home))
}
pub fn services(cfg: &mut web::ServiceConfig) {
cfg.service(list_hooks);
}

View File

@ -0,0 +1,69 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_web::*;
use serde::{Deserialize, Serialize};
use super::get_auth_middleware;
pub use super::home::TemplateSite;
pub use super::{context, Footer, TemplateFile, PAGES, PAYLOAD_KEY, TEMPLATES};
use crate::ctx::Ctx;
use crate::db::ForgejoWebhook;
pub mod add;
pub mod list;
pub mod view;
pub fn register_templates(t: &mut tera::Tera) {
add::DASH_FORGEJO_WEBHOOK_ADD
.register(t)
.expect(add::DASH_FORGEJO_WEBHOOK_ADD.name);
list::DASH_FORGEJO_WEBHOOK_LIST
.register(t)
.expect(list::DASH_FORGEJO_WEBHOOK_LIST.name);
view::DASH_FORGEJO_WEBHOOK_VIEW
.register(t)
.expect(view::DASH_FORGEJO_WEBHOOK_VIEW.name);
}
pub fn services(cfg: &mut web::ServiceConfig) {
add::services(cfg);
list::services(cfg);
view::services(cfg);
}
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
pub struct TemplateForgejoWebhook {
pub webhook: ForgejoWebhook,
pub view: String,
pub url: String,
}
impl TemplateForgejoWebhook {
pub fn new(ctx: &Ctx, hook: ForgejoWebhook) -> Self {
let view = PAGES.dash.forgejo_webhook.get_view(&hook.auth_token);
let url = crate::V1_API_ROUTES
.forgejo
.get_webhook_url(ctx, &hook.auth_token);
Self {
webhook: hook,
view,
url,
}
}
}

View File

@ -0,0 +1,108 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::cell::RefCell;
use actix_identity::Identity;
use actix_web::http::header::ContentType;
use serde::{Deserialize, Serialize};
use tera::Context;
use super::get_auth_middleware;
use crate::pages::errors::*;
use crate::settings::Settings;
use crate::AppCtx;
pub use super::*;
pub const DASH_FORGEJO_WEBHOOK_VIEW: TemplateFile =
TemplateFile::new("dash_forgejo_webhook_view", "pages/dash/forgejo/view.html");
const SHOW_FORGEJO_WEBHOOK_SECRET_KEY: &str = "show_forgejo_webhook_secret";
pub struct View {
ctx: RefCell<Context>,
}
impl CtxError for View {
fn with_error(&self, e: &ReadableError) -> String {
self.ctx.borrow_mut().insert(ERROR_KEY, e);
self.render()
}
}
impl View {
pub fn new(settings: &Settings, payload: Option<TemplateForgejoWebhook>) -> Self {
let ctx = RefCell::new(context(settings));
if let Some(payload) = payload {
ctx.borrow_mut().insert(PAYLOAD_KEY, &payload);
}
Self { ctx }
}
pub fn show_forgejo_webhook_secret(&mut self) {
self.ctx
.borrow_mut()
.insert(SHOW_FORGEJO_WEBHOOK_SECRET_KEY, &true);
}
pub fn render(&self) -> String {
TEMPLATES
.render(DASH_FORGEJO_WEBHOOK_VIEW.name, &self.ctx.borrow())
.unwrap()
}
}
#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)]
pub struct ViewOptions {
show_forgejo_webhook_secret: Option<bool>,
}
#[actix_web_codegen_const_routes::get(
path = "PAGES.dash.forgejo_webhook.view",
wrap = "get_auth_middleware()"
)]
#[tracing::instrument(name = "Dashboard Forgejo webhook webpage", skip(ctx, id))]
pub async fn get_view_site(
ctx: AppCtx,
id: Identity,
path: web::Path<String>,
query: web::Query<ViewOptions>,
) -> PageResult<impl Responder, View> {
let auth_token = path.into_inner();
let owner = id.identity().unwrap();
let hook = ctx
.db
.get_webhook_with_owner(&auth_token, &owner)
.await
.map_err(|e| PageError::new(View::new(&ctx.settings, None), e))?;
let payload = TemplateForgejoWebhook::new(&ctx, hook);
let mut page = View::new(&ctx.settings, Some(payload));
if let Some(true) = query.show_forgejo_webhook_secret {
page.show_forgejo_webhook_secret();
}
let add = page.render();
let html = ContentType::html();
Ok(HttpResponse::Ok().content_type(html).body(add))
}
pub fn services(cfg: &mut web::ServiceConfig) {
cfg.service(get_view_site);
}

157
src/pages/dash/home.rs Normal file
View File

@ -0,0 +1,157 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::cell::RefCell;
use actix_identity::Identity;
use actix_web::http::header::ContentType;
use serde::{Deserialize, Serialize};
use tera::Context;
use super::get_auth_middleware;
use crate::db::Site;
use crate::errors::ServiceResult;
use crate::pages::errors::*;
use crate::settings::Settings;
use crate::AppCtx;
use super::TemplateSiteEvent;
pub use super::*;
pub const DASH_HOME: TemplateFile = TemplateFile::new("dash_home", "pages/dash/index.html");
pub struct Home {
ctx: RefCell<Context>,
}
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
pub struct TemplateSite {
pub site: Site,
pub view: String,
pub last_update: Option<TemplateSiteEvent>,
}
impl TemplateSite {
pub fn new(site: Site, last_update: Option<TemplateSiteEvent>) -> Self {
let view = PAGES.dash.site.get_view(site.pub_id);
Self {
site,
last_update,
view,
}
}
}
impl CtxError for Home {
fn with_error(&self, e: &ReadableError) -> String {
self.ctx.borrow_mut().insert(ERROR_KEY, e);
self.render()
}
}
impl Home {
pub fn new(settings: &Settings, sites: Option<&[TemplateSite]>) -> Self {
let ctx = RefCell::new(context(settings));
if let Some(sites) = sites {
ctx.borrow_mut().insert(PAYLOAD_KEY, sites);
}
Self { ctx }
}
pub fn render(&self) -> String {
TEMPLATES
.render(DASH_HOME.name, &self.ctx.borrow())
.unwrap()
}
}
async fn get_site_data(ctx: &AppCtx, id: &Identity) -> ServiceResult<Vec<TemplateSite>> {
let db_sites = ctx.db.list_all_sites(&id.identity().unwrap()).await?;
let mut sites = Vec::with_capacity(db_sites.len());
for site in db_sites {
// TODO: impl method on DB to get latest "update" event
let last_update = ctx
.db
.get_latest_update_event(&site.hostname)
.await?
.map(|e| e.into());
sites.push(TemplateSite::new(site, last_update));
}
Ok(sites)
}
#[actix_web_codegen_const_routes::get(path = "PAGES.dash.home", wrap = "get_auth_middleware()")]
#[tracing::instrument(name = "Dashboard homepage", skip(ctx, id))]
pub async fn get_home(ctx: AppCtx, id: Identity) -> PageResult<impl Responder, Home> {
let sites = get_site_data(&ctx, &id)
.await
.map_err(|e| PageError::new(Home::new(&ctx.settings, None), e))?;
let home = Home::new(&ctx.settings, Some(&sites)).render();
let html = ContentType::html();
Ok(HttpResponse::Ok().content_type(html).body(home))
}
pub fn services(cfg: &mut web::ServiceConfig) {
cfg.service(get_home);
}
#[cfg(test)]
mod tests {
use actix_web::http::StatusCode;
use actix_web::test;
use crate::ctx::ArcCtx;
use crate::tests;
use crate::*;
use super::PAGES;
#[actix_rt::test]
async fn postgres_dash_home_works() {
let (_, ctx) = tests::get_ctx().await;
dashboard_home_works(ctx.clone()).await;
}
async fn dashboard_home_works(ctx: ArcCtx) {
const NAME: &str = "testdashuser";
const EMAIL: &str = "testdashuser@foo.com";
const PASSWORD: &str = "longpassword";
let _ = ctx.delete_user(NAME, PASSWORD).await;
let (_, signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await;
let cookies = get_cookie!(signin_resp);
let app = get_app!(ctx).await;
let resp = get_request!(&app, PAGES.dash.home, cookies.clone());
assert_eq!(resp.status(), StatusCode::OK);
let res = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap();
println!("before adding site: {res}");
assert!(res.contains("Nothing to show"));
let page = ctx.add_test_site(NAME.into()).await;
let resp = get_request!(&app, PAGES.dash.home, cookies.clone());
assert_eq!(resp.status(), StatusCode::OK);
let res = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap();
println!("after adding site: {res}");
assert!(!res.contains("Nothing here"));
assert!(res.contains(&page.domain));
assert!(res.contains(&page.repo));
let _ = ctx.delete_user(NAME, PASSWORD).await;
}
}

60
src/pages/dash/mod.rs Normal file
View File

@ -0,0 +1,60 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_web::*;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub use super::get_auth_middleware;
pub use super::{context, Footer, TemplateFile, PAGES, PAYLOAD_KEY, TEMPLATES};
use crate::db::Event;
use crate::db::LibrePagesEvent;
pub mod forgejo;
pub mod home;
pub mod sites;
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct TemplateSiteEvent {
pub event_type: Event,
pub time: i64,
pub site: String,
pub id: Uuid,
}
impl From<LibrePagesEvent> for TemplateSiteEvent {
fn from(e: LibrePagesEvent) -> Self {
Self {
event_type: e.event_type,
time: e.time.unix_timestamp(),
site: e.site,
id: e.id,
}
}
}
pub fn register_templates(t: &mut tera::Tera) {
home::DASH_HOME.register(t).expect(home::DASH_HOME.name);
sites::register_templates(t);
forgejo::register_templates(t);
}
pub fn services(cfg: &mut web::ServiceConfig) {
home::services(cfg);
sites::services(cfg);
forgejo::services(cfg);
}

230
src/pages/dash/sites/add.rs Normal file
View File

@ -0,0 +1,230 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::cell::RefCell;
use actix_identity::Identity;
use actix_web::http::header::ContentType;
use serde::{Deserialize, Serialize};
use tera::Context;
use super::get_auth_middleware;
use crate::ctx::api::v1::pages::AddSite;
use crate::pages::errors::*;
use crate::settings::Settings;
use crate::AppCtx;
pub use super::*;
pub const DASH_SITE_ADD: TemplateFile =
TemplateFile::new("dash_site_add", "pages/dash/sites/add.html");
pub struct Add {
ctx: RefCell<Context>,
}
impl CtxError for Add {
fn with_error(&self, e: &ReadableError) -> String {
self.ctx.borrow_mut().insert(ERROR_KEY, e);
self.render()
}
}
impl Add {
pub fn new(settings: &Settings) -> Self {
let ctx = RefCell::new(context(settings));
Self { ctx }
}
pub fn render(&self) -> String {
TEMPLATES
.render(DASH_SITE_ADD.name, &self.ctx.borrow())
.unwrap()
}
}
#[actix_web_codegen_const_routes::get(path = "PAGES.dash.site.add", wrap = "get_auth_middleware()")]
#[tracing::instrument(name = "Dashboard add site webpage", skip(ctx))]
pub async fn get_add_site(ctx: AppCtx) -> PageResult<impl Responder, Add> {
let add = Add::new(&ctx.settings).render();
let html = ContentType::html();
Ok(HttpResponse::Ok().content_type(html).body(add))
}
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
/// Data required to add site
pub struct TemplateAddSite {
pub repo_url: String,
pub branch: String,
}
#[actix_web_codegen_const_routes::post(
path = "PAGES.dash.site.add",
wrap = "get_auth_middleware()"
)]
#[tracing::instrument(name = "Post Dashboard add site webpage", skip(ctx, id))]
pub async fn post_add_site(
ctx: AppCtx,
id: Identity,
payload: web::Form<TemplateAddSite>,
) -> PageResult<impl Responder, Add> {
let owner = id.identity().unwrap();
let payload = payload.into_inner();
let msg = AddSite {
branch: payload.branch,
repo_url: payload.repo_url,
owner,
};
let page = ctx
.add_site(msg)
.await
.map_err(|e| PageError::new(Add::new(&ctx.settings), e))?;
Ok(HttpResponse::Found()
.append_header((
http::header::LOCATION,
PAGES.dash.site.get_view(page.pub_id),
))
.finish())
}
pub fn services(cfg: &mut web::ServiceConfig) {
cfg.service(get_add_site);
cfg.service(post_add_site);
}
#[cfg(test)]
mod tests {
use actix_web::http::StatusCode;
use actix_web::test;
use crate::ctx::api::v1::auth::Password;
use crate::ctx::ArcCtx;
use crate::errors::ServiceError;
use crate::pages::dash::sites::add::TemplateAddSite;
use crate::tests;
use crate::*;
use super::PAGES;
#[actix_rt::test]
async fn postgres_dashboard_add_site_works() {
let (_, ctx) = tests::get_ctx().await;
dashboard_add_site_works(ctx.clone()).await;
}
async fn dashboard_add_site_works(ctx: ArcCtx) {
const NAME: &str = "testdashaddsiteuser";
const EMAIL: &str = "testdashaddsiteuser@foo.com";
const PASSWORD: &str = "longpassword";
let _ = ctx.delete_user(NAME, PASSWORD).await;
let (_, signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await;
let cookies = get_cookie!(signin_resp);
let app = get_app!(ctx.clone()).await;
let resp = get_request!(&app, PAGES.dash.site.add, cookies.clone());
assert_eq!(resp.status(), StatusCode::OK);
let res = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap();
assert!(res.contains("Add Site"));
let payload = TemplateAddSite {
repo_url: tests::REPO_URL.into(),
branch: tests::BRANCH.into(),
};
let add_site = test::call_service(
&app,
post_request!(&payload, PAGES.dash.site.add, FORM)
.cookie(cookies.clone())
.to_request(),
)
.await;
assert_eq!(add_site.status(), StatusCode::FOUND);
let mut site = ctx.db.list_all_sites(NAME).await.unwrap();
let site = site.pop().unwrap();
let mut event = ctx.db.list_all_site_events(&site.hostname).await.unwrap();
let event = event.pop().unwrap();
let headers = add_site.headers();
let view_site = &PAGES.dash.site.get_view(site.pub_id);
assert_eq!(
headers.get(actix_web::http::header::LOCATION).unwrap(),
view_site
);
// view site
let resp = get_request!(&app, view_site, cookies.clone());
assert_eq!(resp.status(), StatusCode::OK);
let res = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap();
assert!(res.contains("****"));
assert!(res.contains(&site.hostname));
assert!(res.contains(&site.repo_url));
assert!(res.contains(&site.branch));
assert!(res.contains(&event.event_type.name));
assert!(res.contains(&event.id.to_string()));
let show_deploy_secret_route = format!("{view_site}?show_deploy_secret=true");
let resp = get_request!(&app, &show_deploy_secret_route, cookies.clone());
assert_eq!(resp.status(), StatusCode::OK);
let res = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap();
assert!(res.contains(&site.site_secret));
// delete site
let delete_site = &PAGES.dash.site.get_delete(site.pub_id);
let resp = get_request!(&app, delete_site, cookies.clone());
assert_eq!(resp.status(), StatusCode::OK);
let res = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap();
assert!(res.contains(&site.hostname));
let msg = Password {
password: PASSWORD.into(),
};
let resp = test::call_service(
&app,
post_request!(&msg, delete_site, FORM)
.cookie(cookies.clone())
.to_request(),
)
.await;
// delete_request!(&app, delete_site, cookies.clone(), &msg, FORM);
assert_eq!(resp.status(), StatusCode::FOUND);
let headers = resp.headers();
assert_eq!(
headers.get(actix_web::http::header::LOCATION).unwrap(),
PAGES.dash.home,
);
assert!(!utils::get_website_path(&ctx.settings, &site.hostname).exists());
assert_eq!(
ctx.db
.get_site_from_pub_id(site.pub_id, NAME.into())
.await
.err(),
Some(ServiceError::WebsiteNotFound)
);
let mut events = ctx.db.list_all_site_events(&site.hostname).await.unwrap();
let possible_delete = events.pop().unwrap();
assert_eq!(&possible_delete.event_type, &*crate::db::EVENT_TYPE_DELETE);
let _ = ctx.delete_user(NAME, PASSWORD).await;
}
}

View File

@ -0,0 +1,185 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::cell::RefCell;
use actix_identity::Identity;
use actix_web::http::header::ContentType;
use serde::{Deserialize, Serialize};
use tera::Context;
use uuid::Uuid;
use super::get_auth_middleware;
use crate::ctx::api::v1::auth::{Login, Password};
use crate::db::Site;
use crate::pages::dash::TemplateSiteEvent;
use crate::pages::errors::*;
use crate::settings::Settings;
use crate::AppCtx;
pub use super::*;
pub const DASH_SITE_DELETE: TemplateFile =
TemplateFile::new("dash_site_delete", "pages/dash/sites/delete.html");
const SHOW_DEPLOY_SECRET_KEY: &str = "show_deploy_secret";
pub struct Delete {
ctx: RefCell<Context>,
}
impl CtxError for Delete {
fn with_error(&self, e: &ReadableError) -> String {
self.ctx.borrow_mut().insert(ERROR_KEY, e);
self.render()
}
}
impl Delete {
pub fn new(settings: &Settings, payload: Option<TemplateSiteWithEvents>) -> Self {
let ctx = RefCell::new(context(settings));
if let Some(payload) = payload {
ctx.borrow_mut().insert(PAYLOAD_KEY, &payload);
}
Self { ctx }
}
pub fn show_deploy_secret(&mut self) {
self.ctx.borrow_mut().insert(SHOW_DEPLOY_SECRET_KEY, &true);
}
pub fn render(&self) -> String {
TEMPLATES
.render(DASH_SITE_DELETE.name, &self.ctx.borrow())
.unwrap()
}
}
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
pub struct TemplateSiteWithEvents {
pub site: Site,
pub delete: String,
pub last_update: Option<TemplateSiteEvent>,
pub events: Vec<TemplateSiteEvent>,
}
impl TemplateSiteWithEvents {
pub fn new(
site: Site,
last_update: Option<TemplateSiteEvent>,
events: Vec<TemplateSiteEvent>,
) -> Self {
let delete = PAGES.dash.site.get_delete(site.pub_id);
Self {
site,
last_update,
delete,
events,
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)]
pub struct DeleteOptions {
show_deploy_secret: Option<bool>,
}
#[actix_web_codegen_const_routes::get(
path = "PAGES.dash.site.delete",
wrap = "get_auth_middleware()"
)]
#[tracing::instrument(name = "Dashboard delete site webpage", skip(ctx, id))]
pub async fn get_delete_site(
ctx: AppCtx,
id: Identity,
path: web::Path<Uuid>,
query: web::Query<DeleteOptions>,
) -> PageResult<impl Responder, Delete> {
let site_id = path.into_inner();
let owner = id.identity().unwrap();
let site = ctx
.db
.get_site_from_pub_id(site_id, owner)
.await
.map_err(|e| PageError::new(Delete::new(&ctx.settings, None), e))?;
let last_update = ctx
.db
.get_latest_update_event(&site.hostname)
.await
.map_err(|e| PageError::new(Delete::new(&ctx.settings, None), e))?;
let last_update = last_update.map(|e| e.into());
let mut db_events = ctx
.db
.list_all_site_events(&site.hostname)
.await
.map_err(|e| PageError::new(Delete::new(&ctx.settings, None), e))?;
let mut events = Vec::with_capacity(db_events.len());
for e in db_events.drain(0..) {
events.push(e.into());
}
let payload = TemplateSiteWithEvents::new(site, last_update, events);
let mut page = Delete::new(&ctx.settings, Some(payload));
if let Some(true) = query.show_deploy_secret {
page.show_deploy_secret();
}
let add = page.render();
let html = ContentType::html();
Ok(HttpResponse::Ok().content_type(html).body(add))
}
#[actix_web_codegen_const_routes::post(
path = "PAGES.dash.site.delete",
wrap = "get_auth_middleware()"
)]
#[tracing::instrument(name = "Delete site from webpage", skip(ctx, id))]
pub async fn post_delete_site(
ctx: AppCtx,
id: Identity,
path: web::Path<Uuid>,
payload: web::Form<Password>,
) -> PageResult<impl Responder, Delete> {
let site_id = path.into_inner();
let owner = id.identity().unwrap();
let payload = payload.into_inner();
let msg = Login {
login: owner,
password: payload.password,
};
ctx.login(&msg)
.await
.map_err(|e| PageError::new(Delete::new(&ctx.settings, None), e))?;
ctx.delete_site(msg.login, site_id)
.await
.map_err(|e| PageError::new(Delete::new(&ctx.settings, None), e))?;
Ok(HttpResponse::Found()
.append_header((http::header::LOCATION, PAGES.dash.home))
.finish())
}
pub fn services(cfg: &mut web::ServiceConfig) {
cfg.service(get_delete_site);
cfg.service(post_delete_site);
}

View File

@ -14,32 +14,30 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_web::web;
use actix_web::*;
use crate::deploy::routes::Deploy;
use crate::meta::routes::Meta;
use crate::serve::routes::Serve;
use super::get_auth_middleware;
pub use super::home::TemplateSite;
pub use super::{context, Footer, TemplateFile, PAGES, PAYLOAD_KEY, TEMPLATES};
pub const ROUTES: Routes = Routes::new();
pub mod add;
pub mod delete;
pub mod view;
pub struct Routes {
pub meta: Meta,
pub deploy: Deploy,
pub serve: Serve,
}
impl Routes {
pub const fn new() -> Self {
Self {
meta: Meta::new(),
deploy: Deploy::new(),
serve: Serve::new(),
}
}
pub fn register_templates(t: &mut tera::Tera) {
add::DASH_SITE_ADD
.register(t)
.expect(add::DASH_SITE_ADD.name);
view::DASH_SITE_VIEW
.register(t)
.expect(view::DASH_SITE_VIEW.name);
delete::DASH_SITE_DELETE
.register(t)
.expect(delete::DASH_SITE_DELETE.name);
}
pub fn services(cfg: &mut web::ServiceConfig) {
crate::meta::services(cfg);
crate::deploy::services(cfg);
crate::serve::services(cfg);
add::services(cfg);
view::services(cfg);
delete::services(cfg);
}

View File

@ -0,0 +1,154 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::cell::RefCell;
use actix_identity::Identity;
use actix_web::http::header::ContentType;
use serde::{Deserialize, Serialize};
use tera::Context;
use uuid::Uuid;
use super::get_auth_middleware;
use crate::db::Site;
use crate::pages::dash::TemplateSiteEvent;
use crate::pages::errors::*;
use crate::settings::Settings;
use crate::AppCtx;
pub use super::*;
pub const DASH_SITE_VIEW: TemplateFile =
TemplateFile::new("dash_site_view", "pages/dash/sites/view.html");
const SHOW_DEPLOY_SECRET_KEY: &str = "show_deploy_secret";
pub struct View {
ctx: RefCell<Context>,
}
impl CtxError for View {
fn with_error(&self, e: &ReadableError) -> String {
self.ctx.borrow_mut().insert(ERROR_KEY, e);
self.render()
}
}
impl View {
pub fn new(settings: &Settings, payload: Option<TemplateSiteWithEvents>) -> Self {
let ctx = RefCell::new(context(settings));
if let Some(payload) = payload {
ctx.borrow_mut().insert(PAYLOAD_KEY, &payload);
}
Self { ctx }
}
pub fn show_deploy_secret(&mut self) {
self.ctx.borrow_mut().insert(SHOW_DEPLOY_SECRET_KEY, &true);
}
pub fn render(&self) -> String {
TEMPLATES
.render(DASH_SITE_VIEW.name, &self.ctx.borrow())
.unwrap()
}
}
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
pub struct TemplateSiteWithEvents {
pub site: Site,
pub view: String,
pub delete: String,
pub last_update: Option<TemplateSiteEvent>,
pub events: Vec<TemplateSiteEvent>,
}
impl TemplateSiteWithEvents {
pub fn new(
site: Site,
last_update: Option<TemplateSiteEvent>,
events: Vec<TemplateSiteEvent>,
) -> Self {
let view = PAGES.dash.site.get_view(site.pub_id);
let delete = PAGES.dash.site.get_delete(site.pub_id);
Self {
site,
last_update,
view,
delete,
events,
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)]
pub struct ViewOptions {
show_deploy_secret: Option<bool>,
}
#[actix_web_codegen_const_routes::get(
path = "PAGES.dash.site.view",
wrap = "get_auth_middleware()"
)]
#[tracing::instrument(name = "Dashboard add site webpage", skip(ctx, id))]
pub async fn get_view_site(
ctx: AppCtx,
id: Identity,
path: web::Path<Uuid>,
query: web::Query<ViewOptions>,
) -> PageResult<impl Responder, View> {
let site_id = path.into_inner();
let owner = id.identity().unwrap();
let site = ctx
.db
.get_site_from_pub_id(site_id, owner)
.await
.map_err(|e| PageError::new(View::new(&ctx.settings, None), e))?;
let last_update = ctx
.db
.get_latest_update_event(&site.hostname)
.await
.map_err(|e| PageError::new(View::new(&ctx.settings, None), e))?;
let last_update = last_update.map(|e| e.into());
let mut db_events = ctx
.db
.list_all_site_events(&site.hostname)
.await
.map_err(|e| PageError::new(View::new(&ctx.settings, None), e))?;
let mut events = Vec::with_capacity(db_events.len());
for e in db_events.drain(0..) {
events.push(e.into());
}
let payload = TemplateSiteWithEvents::new(site, last_update, events);
let mut page = View::new(&ctx.settings, Some(payload));
if let Some(true) = query.show_deploy_secret {
page.show_deploy_secret();
}
let add = page.render();
let html = ContentType::html();
Ok(HttpResponse::Ok().content_type(html).body(add))
}
pub fn services(cfg: &mut web::ServiceConfig) {
cfg.service(get_view_site);
}

105
src/pages/errors.rs Normal file
View File

@ -0,0 +1,105 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::fmt;
use actix_web::{
error::ResponseError,
http::{header::ContentType, StatusCode},
HttpResponse, HttpResponseBuilder,
};
use derive_more::Display;
use derive_more::Error;
use serde::*;
use super::TemplateFile;
use crate::errors::ServiceError;
pub const ERROR_KEY: &str = "error";
pub const ERROR_TEMPLATE: TemplateFile = TemplateFile::new("error_comp", "components/error.html");
pub fn register_templates(t: &mut tera::Tera) {
ERROR_TEMPLATE.register(t).expect(ERROR_TEMPLATE.name);
}
/// Render template with error context
pub trait CtxError {
fn with_error(&self, e: &ReadableError) -> String;
}
#[derive(Serialize, Debug, Display, Clone)]
#[display(fmt = "title: {} reason: {}", title, reason)]
pub struct ReadableError {
pub reason: String,
pub title: String,
}
impl ReadableError {
pub fn new(e: &ServiceError) -> Self {
let reason = format!("{}", e);
let title = format!("{}", e.status_code());
Self { reason, title }
}
}
#[derive(Error, Display)]
#[display(fmt = "{}", readable)]
pub struct PageError<T> {
#[error(not(source))]
template: T,
readable: ReadableError,
#[error(not(source))]
error: ServiceError,
}
impl<T> fmt::Debug for PageError<T> {
#[cfg(not(tarpaulin_include))]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PageError")
.field("readable", &self.readable)
.finish()
}
}
impl<T: CtxError> PageError<T> {
/// create new instance of [PageError] from a template and an error
pub fn new(template: T, error: ServiceError) -> Self {
let readable = ReadableError::new(&error);
Self {
error,
template,
readable,
}
}
}
#[cfg(not(tarpaulin_include))]
impl<T: CtxError> ResponseError for PageError<T> {
fn error_response(&self) -> HttpResponse {
HttpResponseBuilder::new(self.status_code())
.content_type(ContentType::html())
.body(self.template.with_error(&self.readable))
}
fn status_code(&self) -> StatusCode {
self.error.status_code()
}
}
/// Generic result data structure
#[cfg(not(tarpaulin_include))]
pub type PageResult<V, T> = std::result::Result<V, PageError<T>>;

203
src/pages/mod.rs Normal file
View File

@ -0,0 +1,203 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_identity::Identity;
use actix_web::http::header;
use actix_web::*;
use lazy_static::lazy_static;
use rust_embed::RustEmbed;
use serde::*;
use tera::*;
use crate::settings::Settings;
use crate::static_assets::ASSETS;
use crate::{GIT_COMMIT_HASH, VERSION};
pub mod auth;
pub mod dash;
pub mod errors;
pub mod routes;
pub use routes::get_auth_middleware;
pub use routes::PAGES;
pub struct TemplateFile {
pub name: &'static str,
pub path: &'static str,
}
impl TemplateFile {
pub const fn new(name: &'static str, path: &'static str) -> Self {
Self { name, path }
}
pub fn register(&self, t: &mut Tera) -> std::result::Result<(), tera::Error> {
t.add_raw_template(self.name, &Templates::get_template(self).expect(self.name))
}
#[cfg(test)]
#[allow(dead_code)]
pub fn register_from_file(&self, t: &mut Tera) -> std::result::Result<(), tera::Error> {
use std::path::Path;
t.add_template_file(Path::new("templates/").join(self.path), Some(self.name))
}
}
pub const PAYLOAD_KEY: &str = "payload";
pub const BASE: TemplateFile = TemplateFile::new("base", "components/base.html");
pub const FOOTER: TemplateFile = TemplateFile::new("footer", "components/footer.html");
pub const PUB_NAV: TemplateFile = TemplateFile::new("pub_nav", "components/nav/pub.html");
pub const AUTH_NAV: TemplateFile = TemplateFile::new("auth_nav", "components/nav/auth.html");
lazy_static! {
pub static ref TEMPLATES: Tera = {
let mut tera = Tera::default();
for t in [BASE, FOOTER, PUB_NAV, AUTH_NAV].iter() {
t.register(&mut tera).unwrap();
}
errors::register_templates(&mut tera);
tera.autoescape_on(vec![".html", ".sql"]);
auth::register_templates(&mut tera);
dash::register_templates(&mut tera);
tera
};
}
#[derive(RustEmbed)]
#[folder = "templates/"]
pub struct Templates;
impl Templates {
pub fn get_template(t: &TemplateFile) -> Option<String> {
match Self::get(t.path) {
Some(file) => Some(String::from_utf8_lossy(&file.data).into_owned()),
None => None,
}
}
}
pub fn context(s: &Settings) -> Context {
let mut ctx = Context::new();
let footer = Footer::new(s);
ctx.insert("footer", &footer);
ctx.insert("page", &PAGES);
ctx.insert("assets", &*ASSETS);
ctx
}
pub fn auth_ctx(_username: Option<&str>, s: &Settings) -> Context {
let mut ctx = Context::new();
let footer = Footer::new(s);
ctx.insert("footer", &footer);
ctx.insert("page", &PAGES);
ctx.insert("assets", &*ASSETS);
// ctx.insert("loggedin_user", &profile_link);
ctx
}
#[derive(Serialize)]
pub struct Footer<'a> {
version: &'a str,
support_email: &'a str,
source_code: &'a str,
git_hash: &'a str,
settings: &'a Settings,
}
impl<'a> Footer<'a> {
pub fn new(settings: &'a Settings) -> Self {
Self {
version: VERSION,
source_code: &settings.source_code,
support_email: &settings.support_email,
git_hash: &GIT_COMMIT_HASH[..8],
settings,
}
}
}
pub async fn home(id: &Identity) -> HttpResponse {
let location = if id.identity().is_some() {
PAGES.home
} else {
PAGES.dash.home
};
HttpResponse::Found()
.append_header((header::LOCATION, location))
.finish()
}
pub fn services(cfg: &mut web::ServiceConfig) {
dash::services(cfg);
auth::services(cfg);
}
#[cfg(test)]
mod tests {
#[test]
fn templates_work_basic() {
use super::*;
use tera::Tera;
let mut tera = Tera::default();
let mut tera2 = Tera::default();
for t in [
BASE,
FOOTER,
PUB_NAV,
AUTH_NAV,
auth::AUTH_BASE,
auth::login::LOGIN,
auth::register::REGISTER,
errors::ERROR_TEMPLATE,
super::dash::home::DASH_HOME,
super::dash::sites::add::DASH_SITE_ADD,
]
.iter()
{
t.register_from_file(&mut tera2).unwrap();
t.register(&mut tera).unwrap();
}
}
}
#[cfg(test)]
mod http_page_tests {
use actix_web::http::StatusCode;
use actix_web::test;
use crate::ctx::ArcCtx;
use crate::*;
use super::PAGES;
#[actix_rt::test]
async fn postgrest_templates_work() {
let (_, ctx) = crate::tests::get_ctx().await;
templates_work(ctx).await;
}
async fn templates_work(ctx: ArcCtx) {
let app = get_app!(ctx).await;
for file in [PAGES.auth.login, PAGES.auth.register, PAGES.home].iter() {
let resp = get_request!(&app, file);
assert_eq!(resp.status(), StatusCode::OK);
}
}
}

168
src/pages/routes.rs Normal file
View File

@ -0,0 +1,168 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_auth_middleware::{Authentication, GetLoginRoute};
use serde::*;
use uuid::Uuid;
/// constant [Pages](Pages) instance
pub const PAGES: Pages = Pages::new();
#[derive(Serialize)]
/// Top-level routes data structure for V1 AP1
pub struct Pages {
/// Authentication routes
pub auth: Auth,
/// home page
pub home: &'static str,
pub dash: Dash,
}
impl Pages {
/// create new instance of Routes
const fn new() -> Pages {
let auth = Auth::new();
let dash = Dash::new();
let home = auth.login;
Pages { auth, home, dash }
}
}
#[derive(Serialize)]
/// Authentication routes
pub struct Auth {
/// logout route
pub logout: &'static str,
/// login route
pub login: &'static str,
/// registration route
pub register: &'static str,
}
impl Auth {
/// create new instance of Authentication route
pub const fn new() -> Auth {
let login = "/login";
let logout = "/logout";
let register = "/join";
Auth {
logout,
login,
register,
}
}
}
#[derive(Serialize)]
/// Dashboard routes
pub struct Dash {
/// home route
pub home: &'static str,
pub site: DashSite,
pub forgejo_webhook: ForgejoWebhook,
}
impl Dash {
/// create new instance of Dash route
pub const fn new() -> Dash {
let home = "/dash";
let site = DashSite::new();
let forgejo_webhook = ForgejoWebhook::new();
Dash {
home,
site,
forgejo_webhook,
}
}
}
#[derive(Serialize)]
/// Dashboard ForgejoWebhook routes
pub struct ForgejoWebhook {
/// add forgejo webhook route
pub add: &'static str,
/// view forgejo webhook route
pub view: &'static str,
/// list forgejo webhooks route
pub list: &'static str,
}
impl ForgejoWebhook {
/// create new instance of ForgejoWebhook route
pub const fn new() -> ForgejoWebhook {
let add = "/dash/forgejo/webhook/add";
let list = "/dash/forgejo/webhook/list";
let view = "/dash/forgejo/webhook/view/{auth_token}";
ForgejoWebhook { add, view, list }
}
pub fn get_view(&self, auth_token: &str) -> String {
self.view.replace("{auth_token}", auth_token)
}
}
#[derive(Serialize)]
/// Dashboard Site routes
pub struct DashSite {
/// add site route
pub add: &'static str,
/// view site route
pub view: &'static str,
/// delete site route
pub delete: &'static str,
}
impl DashSite {
/// create new instance of DashSite route
pub const fn new() -> DashSite {
let add = "/dash/site/add";
let view = "/dash/site/view/{deployment_pub_id}";
let delete = "/dash/site/delete/{deployment_pub_id}";
DashSite { add, view, delete }
}
pub fn get_view(&self, deployment_pub_id: Uuid) -> String {
self.view.replace(
"{deployment_pub_id}",
deployment_pub_id.to_string().as_ref(),
)
}
pub fn get_delete(&self, deployment_pub_id: Uuid) -> String {
self.delete.replace(
"{deployment_pub_id}",
deployment_pub_id.to_string().as_ref(),
)
}
}
pub fn get_auth_middleware() -> Authentication<Pages> {
Authentication::with_identity(PAGES)
}
impl GetLoginRoute for Pages {
fn get_login_route(&self, src: Option<&str>) -> String {
if let Some(redirect_to) = src {
format!(
"{}?redirect_to={}",
self.auth.login,
urlencoding::encode(redirect_to)
)
} else {
self.auth.login.to_string()
}
}
}

View File

@ -25,7 +25,7 @@ pub struct Preview<'a> {
impl<'a> Preview<'a> {
pub fn new(ctx: &'a AppCtx) -> Self {
Self {
base: &ctx.settings.server.domain,
base: &ctx.settings.page.base_domain,
delimiter: ".",
prefix: "deploy-preview-",
}
@ -38,9 +38,9 @@ impl<'a> Preview<'a> {
}
pub fn extract(&self, hostname: &'a str) -> Option<&'a str> {
if !hostname.contains(&self.delimiter)
|| !hostname.contains(&self.prefix)
|| !hostname.contains(&self.base)
if !hostname.contains(self.delimiter)
|| !hostname.contains(self.prefix)
|| !hostname.contains(self.base)
{
return None;
}

View File

@ -1,3 +1,4 @@
use actix_identity::Identity;
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
@ -14,10 +15,10 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_web::{http::header::ContentType, web, HttpRequest, HttpResponse, Responder};
use actix_web::{web, HttpRequest, HttpResponse, Responder};
use crate::errors::*;
use crate::page::Page;
use crate::pages;
use crate::AppCtx;
pub mod routes {
@ -34,80 +35,61 @@ pub mod routes {
}
}
pub fn find_page<'a>(domain: &str, ctx: &'a AppCtx) -> Option<&'a Page> {
log::info!("looking for {domain}");
for page in ctx.settings.pages.iter() {
log::debug!("configured domains: {}", page.domain);
log::debug!("{}", page.domain.trim() == domain.trim());
if page.domain.trim() == domain.trim() {
log::debug!("found configured domains: {}", page.domain);
return Some(page);
}
}
None
}
#[my_codegen::get(path = "crate::V1_API_ROUTES.serve.catch_all")]
async fn index(req: HttpRequest, ctx: AppCtx) -> ServiceResult<impl Responder> {
#[actix_web_codegen_const_routes::get(path = "crate::V1_API_ROUTES.serve.catch_all")]
#[tracing::instrument(name = "Serve webpages", skip(req, ctx, id))]
async fn index(req: HttpRequest, ctx: AppCtx, id: Identity) -> ServiceResult<impl Responder> {
let c = req.connection_info();
let mut host = c.host();
if host.contains(':') {
host = host.split(':').next().unwrap();
}
tracing::debug!("Current host {host}");
// serve meta page
if host == ctx.settings.server.domain || host == "localhost" {
return Ok(HttpResponse::Ok()
.content_type(ContentType::html())
.body("Welcome to Librepages!"));
tracing::debug!("Into home");
return Ok(pages::home(&id).await);
}
if host.contains(&ctx.settings.server.domain) {
// serve default hostname content
if host.contains(&ctx.settings.page.base_domain) {
let extractor = crate::preview::Preview::new(&ctx);
if let Some(preview_branch) = extractor.extract(host) {
unimplemented!(
"map a local subdomain on settings.server.domain and use it to fetch page"
);
let res = match find_page(host, &ctx) {
Some(page) => {
log::debug!("Page found");
let content = crate::git::read_preview_file(
&page.path,
preview_branch,
req.uri().path(),
)?;
let mime = if let Some(mime) = content.mime.first_raw() {
mime
} else {
"text/html; charset=utf-8"
};
let res = if ctx.db.hostname_exists(host).await? {
let path = crate::utils::get_website_path(&ctx.settings, host);
let content =
crate::git::read_preview_file(&path, preview_branch, req.uri().path())?;
let mime = if let Some(mime) = content.mime.first_raw() {
mime
} else {
"text/html; charset=utf-8"
};
Ok(HttpResponse::Ok()
//.content_type(ContentType::html())
.content_type(mime)
.body(content.content.bytes()))
}
None => Err(ServiceError::WebsiteNotFound),
Ok(HttpResponse::Ok()
.content_type(mime)
.body(content.content.bytes()))
} else {
Err(ServiceError::WebsiteNotFound)
};
return res;
}
}
match find_page(host, &ctx) {
Some(page) => {
log::debug!("Page found");
let content = crate::git::read_file(&page.path, req.uri().path())?;
let mime = if let Some(mime) = content.mime.first_raw() {
mime
} else {
"text/html; charset=utf-8"
};
// TODO: custom domains.
if ctx.db.hostname_exists(host).await? {
let path = crate::utils::get_website_path(&ctx.settings, host);
let content = crate::git::read_file(&path, req.uri().path())?;
let mime = if let Some(mime) = content.mime.first_raw() {
mime
} else {
"text/html; charset=utf-8"
};
Ok(HttpResponse::Ok()
//.content_type(ContentType::html())
.content_type(mime)
.body(content.content.bytes()))
}
None => Err(ServiceError::WebsiteNotFound),
Ok(HttpResponse::Ok()
.content_type(mime)
.body(content.content.bytes()))
} else {
Err(ServiceError::WebsiteNotFound)
}
}

View File

@ -16,26 +16,27 @@
*/
use std::env;
use std::path::Path;
use std::sync::Arc;
use config::{Config, Environment, File};
use config::{Config, ConfigError, Environment, File};
use derive_more::Display;
#[cfg(not(test))]
use log::{error, warn};
use tracing::warn;
#[cfg(test)]
use std::{println as warn, println as error};
use std::println as warn;
use serde::Deserialize;
use serde::Serialize;
use url::Url;
use crate::errors::*;
use crate::page::Page;
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Server {
pub port: u32,
pub ip: String,
pub workers: Option<usize>,
pub cookie_secret: String,
pub domain: String,
}
@ -46,11 +47,55 @@ impl Server {
}
}
#[derive(Debug, Clone, Deserialize)]
#[derive(Deserialize, Serialize, Display, Eq, PartialEq, Clone, Debug)]
#[serde(rename_all = "lowercase")]
pub enum DBType {
#[display(fmt = "postgres")]
Postgres,
// #[display(fmt = "maria")]
// Maria,
}
impl DBType {
fn from_url(url: &Url) -> Result<Self, ConfigError> {
match url.scheme() {
// "mysql" => Ok(Self::Maria),
"postgres" => Ok(Self::Postgres),
_ => Err(ConfigError::Message("Unknown database type".into())),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Database {
pub url: String,
pub pool: u32,
pub database_type: DBType,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
pub allow_registration: bool,
pub support_email: String,
pub debug: bool,
pub server: Server,
pub source_code: String,
pub pages: Vec<Arc<Page>>,
pub database: Database,
pub page: PageConfig,
pub conductors: Vec<Conductor>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Conductor {
pub username: String,
pub api_key: String,
pub url: Url,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageConfig {
pub base_path: String,
pub base_domain: String,
}
#[cfg(not(tarpaulin_include))]
@ -84,50 +129,66 @@ impl Settings {
s = s.add_source(Environment::with_prefix("PAGES").separator("__"));
let mut settings = s.build()?.try_deserialize::<Settings>()?;
settings.check_url();
match env::var("PORT") {
Ok(val) => {
settings.server.port = val.parse().unwrap();
s = s.set_override("server.port", val).unwrap();
}
Err(e) => warn!("couldn't interpret PORT: {}", e),
}
settings.init();
let intermediate_config = s.build_cloned().unwrap();
s = s
.set_override(
"database.url",
format!(
r"postgres://{}:{}@{}:{}/{}",
intermediate_config
.get::<String>("database.username")
.expect("Couldn't access database username"),
intermediate_config
.get::<String>("database.password")
.expect("Couldn't access database password"),
intermediate_config
.get::<String>("database.hostname")
.expect("Couldn't access database hostname"),
intermediate_config
.get::<String>("database.port")
.expect("Couldn't access database port"),
intermediate_config
.get::<String>("database.name")
.expect("Couldn't access database name")
),
)
.expect("Couldn't set database url");
if let Ok(val) = env::var("DATABASE_URL") {
let url = Url::parse(&val).expect("couldn't parse Database URL");
s = s.set_override("database.url", url.to_string()).unwrap();
let database_type = DBType::from_url(&url).unwrap();
s = s
.set_override("database.database_type", database_type.to_string())
.unwrap();
}
let settings = s.build()?.try_deserialize::<Settings>()?;
settings.check_url();
Ok(settings)
}
pub fn init(&self) {
for (index, page) in self.pages.iter().enumerate() {
Url::parse(&page.repo).unwrap();
let path = Path::new(&page.path);
fn create_dir_util(path: &Path) {
if path.exists() && path.is_file() {
panic!("Path is a file, should be a directory: {:?}", page);
panic!("Path is a file, should be a directory: {:?}", path);
}
if !path.exists() {
std::fs::create_dir_all(&path).unwrap();
}
for (index2, page2) in self.pages.iter().enumerate() {
if index2 == index {
continue;
}
if page.secret == page2.secret {
error!("{}", ServiceError::SecretTaken(page.clone(), page2.clone()));
} else if page.repo == page2.repo {
error!(
"{}",
ServiceError::DuplicateRepositoryURL(page.clone(), page2.clone(),)
);
} else if page.path == page2.path {
error!("{}", ServiceError::PathTaken(page.clone(), page2.clone()));
}
}
if let Err(e) = page.update(&page.branch) {
error!("{e}");
std::fs::create_dir_all(path).unwrap();
}
}
create_dir_util(Path::new(&self.page.base_path));
}
#[cfg(not(tarpaulin_include))]

View File

@ -0,0 +1,46 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use cache_buster::Files;
pub struct FileMap {
pub files: Files,
}
impl FileMap {
#[allow(clippy::new_without_default)]
pub fn new() -> Self {
let map = include_str!("../cache_buster_data.json");
let files = Files::new(map);
Self { files }
}
pub fn get(&self, path: impl AsRef<str>) -> Option<&str> {
let file_path = self.files.get_full_path(path);
file_path.map(|file_path| &file_path[1..])
}
}
#[cfg(test)]
mod tests {
#[test]
fn filemap_works() {
let files = super::FileMap::new();
let css = files.get("./static/cache/css/main.css").unwrap();
println!("{}", css);
assert!(css.contains("/assets/css/main"));
}
}

73
src/static_assets/mod.rs Normal file
View File

@ -0,0 +1,73 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_web::*;
pub mod filemap;
pub mod static_files;
pub use filemap::FileMap;
pub use routes::{Assets, ASSETS};
pub fn services(cfg: &mut web::ServiceConfig) {
cfg.service(static_files::static_files);
}
pub mod routes {
use lazy_static::lazy_static;
use serde::*;
use super::*;
lazy_static! {
pub static ref ASSETS: Assets = Assets::new();
}
#[derive(Serialize)]
pub struct Svg {
pub eye_off: &'static str,
pub eye: &'static str,
}
impl Svg {
/// create new instance of Routes
pub fn new() -> Svg {
Svg {
eye: &static_files::assets::CSS,
eye_off: &static_files::assets::CSS,
}
}
}
#[derive(Serialize)]
/// Top-level routes data structure for V1 AP1
pub struct Assets {
/// Authentication routes
pub css: &'static str,
pub mobile_css: &'static str,
pub svg: Svg,
}
impl Assets {
/// create new instance of Routes
pub fn new() -> Assets {
Assets {
css: &static_files::assets::CSS,
mobile_css: &static_files::assets::CSS,
svg: Svg::new(),
}
}
}
}

View File

@ -0,0 +1,97 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::borrow::Cow;
use actix_web::body::BoxBody;
use actix_web::{get, http::header, web, HttpResponse, Responder};
use mime_guess::from_path;
use rust_embed::RustEmbed;
use crate::CACHE_AGE;
pub mod assets {
use crate::FILES;
use lazy_static::lazy_static;
lazy_static! {
pub static ref CSS: &'static str = FILES.get("./static/cache/css/main.css").unwrap();
pub static ref EYE: &'static str = FILES.get("./static/cache/img/svg/eye.svg").unwrap();
pub static ref EYE_OFF: &'static str =
FILES.get("./static/cache/img/svg/eye-off.svg").unwrap();
pub static ref MOBILE_CSS: &'static str =
FILES.get("./static/cache/css/mobile.css").unwrap();
}
}
#[derive(RustEmbed)]
#[folder = "assets/"]
struct Asset;
fn handle_assets(path: &str) -> HttpResponse {
match Asset::get(path) {
Some(content) => {
let body: BoxBody = match content.data {
Cow::Borrowed(bytes) => BoxBody::new(bytes),
Cow::Owned(bytes) => BoxBody::new(bytes),
};
HttpResponse::Ok()
.insert_header(header::CacheControl(vec![
header::CacheDirective::Public,
header::CacheDirective::Extension("immutable".into(), None),
header::CacheDirective::MaxAge(CACHE_AGE),
]))
.content_type(from_path(path).first_or_octet_stream().as_ref())
.body(body)
}
None => HttpResponse::NotFound().body("404 Not Found"),
}
}
#[get("/assets/{_:.*}")]
pub async fn static_files(path: web::Path<String>) -> impl Responder {
handle_assets(&path)
}
#[cfg(test)]
mod tests {
use actix_web::http::StatusCode;
use actix_web::test;
use crate::ctx::ArcCtx;
use crate::tests::*;
use crate::*;
use super::assets::CSS;
use super::assets::MOBILE_CSS;
#[actix_rt::test]
async fn postgrest_static_files_works() {
let (_, ctx) = get_ctx().await;
static_assets_work(ctx).await;
}
async fn static_assets_work(ctx: ArcCtx) {
let app = get_app!(ctx).await;
for file in [*CSS, *MOBILE_CSS].iter() {
println!("testing file {file}");
let resp = get_request!(&app, file);
assert_eq!(resp.status(), StatusCode::OK);
}
}
}

972
src/subdomains.rs Normal file
View File

@ -0,0 +1,972 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::settings::Settings;
// source: https://www.randomlists.com/data/nouns.json
const LEN: usize = 876;
const WORDLIST: [&str; LEN] = [
"account",
"achiever",
"acoustics",
"act",
"action",
"activity",
"actor",
"addition",
"adjustment",
"advertisement",
"advice",
"aftermath",
"afternoon",
"afterthought",
"agreement",
"air",
"airplane",
"airport",
"alarm",
"amount",
"amusement",
"anger",
"angle",
"animal",
"ants",
"apparatus",
"apparel",
"appliance",
"approval",
"arch",
"argument",
"arithmetic",
"arm",
"army",
"art",
"attack",
"attraction",
"aunt",
"authority",
"babies",
"baby",
"back",
"badge",
"bag",
"bait",
"balance",
"ball",
"base",
"baseball",
"basin",
"basket",
"basketball",
"bat",
"bath",
"battle",
"bead",
"bear",
"bed",
"bedroom",
"beds",
"bee",
"beef",
"beginner",
"behavior",
"belief",
"believe",
"bell",
"bells",
"berry",
"bike",
"bikes",
"bird",
"birds",
"birth",
"birthday",
"bit",
"bite",
"blade",
"blood",
"blow",
"board",
"boat",
"bomb",
"bone",
"book",
"books",
"boot",
"border",
"bottle",
"boundary",
"box",
"boy",
"brake",
"branch",
"brass",
"breath",
"brick",
"bridge",
"brother",
"bubble",
"bucket",
"building",
"bulb",
"burst",
"bushes",
"business",
"butter",
"button",
"cabbage",
"cable",
"cactus",
"cake",
"cakes",
"calculator",
"calendar",
"camera",
"camp",
"can",
"cannon",
"canvas",
"cap",
"caption",
"car",
"card",
"care",
"carpenter",
"carriage",
"cars",
"cart",
"cast",
"cat",
"cats",
"cattle",
"cause",
"cave",
"celery",
"cellar",
"cemetery",
"cent",
"chalk",
"chance",
"change",
"channel",
"cheese",
"cherries",
"cherry",
"chess",
"chicken",
"chickens",
"children",
"chin",
"church",
"circle",
"clam",
"class",
"cloth",
"clover",
"club",
"coach",
"coal",
"coast",
"coat",
"cobweb",
"coil",
"collar",
"color",
"committee",
"company",
"comparison",
"competition",
"condition",
"connection",
"control",
"cook",
"copper",
"corn",
"cough",
"country",
"cover",
"cow",
"cows",
"crack",
"cracker",
"crate",
"crayon",
"cream",
"creator",
"creature",
"credit",
"crib",
"crime",
"crook",
"crow",
"crowd",
"crown",
"cub",
"cup",
"current",
"curtain",
"curve",
"cushion",
"dad",
"daughter",
"day",
"death",
"debt",
"decision",
"deer",
"degree",
"design",
"desire",
"desk",
"destruction",
"detail",
"development",
"digestion",
"dime",
"dinner",
"dinosaurs",
"direction",
"dirt",
"discovery",
"discussion",
"distance",
"distribution",
"division",
"dock",
"doctor",
"dog",
"dogs",
"doll",
"dolls",
"donkey",
"door",
"downtown",
"drain",
"drawer",
"dress",
"drink",
"driving",
"drop",
"duck",
"ducks",
"dust",
"ear",
"earth",
"earthquake",
"edge",
"education",
"effect",
"egg",
"eggnog",
"eggs",
"elbow",
"end",
"engine",
"error",
"event",
"example",
"exchange",
"existence",
"expansion",
"experience",
"expert",
"eye",
"eyes",
"face",
"fact",
"fairies",
"fall",
"fang",
"farm",
"fear",
"feeling",
"field",
"finger",
"fire",
"fireman",
"fish",
"flag",
"flame",
"flavor",
"flesh",
"flight",
"flock",
"floor",
"flower",
"flowers",
"fly",
"fog",
"fold",
"food",
"foot",
"force",
"fork",
"form",
"fowl",
"frame",
"friction",
"friend",
"friends",
"frog",
"frogs",
"front",
"fruit",
"fuel",
"furniture",
"gate",
"geese",
"ghost",
"giants",
"giraffe",
"girl",
"girls",
"glass",
"glove",
"gold",
"government",
"governor",
"grade",
"grain",
"grandfather",
"grandmother",
"grape",
"grass",
"grip",
"ground",
"group",
"growth",
"guide",
"guitar",
"gun",
"hair",
"haircut",
"hall",
"hammer",
"hand",
"hands",
"harbor",
"harmony",
"hat",
"hate",
"head",
"health",
"heat",
"hill",
"history",
"hobbies",
"hole",
"holiday",
"home",
"honey",
"hook",
"hope",
"horn",
"horse",
"horses",
"hose",
"hospital",
"hot",
"hour",
"house",
"houses",
"humor",
"hydrant",
"ice",
"icicle",
"idea",
"impulse",
"income",
"increase",
"industry",
"ink",
"insect",
"instrument",
"insurance",
"interest",
"invention",
"iron",
"island",
"jail",
"jam",
"jar",
"jeans",
"jelly",
"jellyfish",
"jewel",
"join",
"judge",
"juice",
"jump",
"kettle",
"key",
"kick",
"kiss",
"kittens",
"kitty",
"knee",
"knife",
"knot",
"knowledge",
"laborer",
"lace",
"ladybug",
"lake",
"lamp",
"land",
"language",
"laugh",
"leather",
"leg",
"legs",
"letter",
"letters",
"lettuce",
"level",
"library",
"limit",
"line",
"linen",
"lip",
"liquid",
"loaf",
"lock",
"locket",
"look",
"loss",
"love",
"low",
"lumber",
"lunch",
"lunchroom",
"machine",
"magic",
"maid",
"mailbox",
"man",
"marble",
"mark",
"market",
"mask",
"mass",
"match",
"meal",
"measure",
"meat",
"meeting",
"memory",
"men",
"metal",
"mice",
"middle",
"milk",
"mind",
"mine",
"minister",
"mint",
"minute",
"mist",
"mitten",
"mom",
"money",
"monkey",
"month",
"moon",
"morning",
"mother",
"motion",
"mountain",
"mouth",
"move",
"muscle",
"name",
"nation",
"neck",
"need",
"needle",
"nerve",
"nest",
"night",
"noise",
"north",
"nose",
"note",
"notebook",
"number",
"nut",
"oatmeal",
"observation",
"ocean",
"offer",
"office",
"oil",
"orange",
"oranges",
"order",
"oven",
"page",
"pail",
"pan",
"pancake",
"paper",
"parcel",
"part",
"partner",
"party",
"passenger",
"payment",
"peace",
"pear",
"pen",
"pencil",
"person",
"pest",
"pet",
"pets",
"pickle",
"picture",
"pie",
"pies",
"pig",
"pigs",
"pin",
"pipe",
"pizzas",
"place",
"plane",
"planes",
"plant",
"plantation",
"plants",
"plastic",
"plate",
"play",
"playground",
"pleasure",
"plot",
"plough",
"pocket",
"point",
"poison",
"pollution",
"popcorn",
"porter",
"position",
"pot",
"potato",
"powder",
"power",
"price",
"produce",
"profit",
"property",
"prose",
"protest",
"pull",
"pump",
"punishment",
"purpose",
"push",
"quarter",
"quartz",
"queen",
"question",
"quicksand",
"quiet",
"quill",
"quilt",
"quince",
"quiver",
"rabbit",
"rabbits",
"rail",
"railway",
"rain",
"rainstorm",
"rake",
"range",
"rat",
"rate",
"ray",
"reaction",
"reading",
"reason",
"receipt",
"recess",
"record",
"regret",
"relation",
"religion",
"representative",
"request",
"respect",
"rest",
"reward",
"rhythm",
"rice",
"riddle",
"rifle",
"ring",
"rings",
"river",
"road",
"robin",
"rock",
"rod",
"roll",
"roof",
"room",
"root",
"rose",
"route",
"rub",
"rule",
"run",
"sack",
"sail",
"salt",
"sand",
"scale",
"scarecrow",
"scarf",
"scene",
"scent",
"school",
"science",
"scissors",
"screw",
"sea",
"seashore",
"seat",
"secretary",
"seed",
"selection",
"self",
"sense",
"servant",
"shade",
"shake",
"shame",
"shape",
"sheep",
"sheet",
"shelf",
"ship",
"shirt",
"shock",
"shoe",
"shoes",
"shop",
"show",
"side",
"sidewalk",
"sign",
"silk",
"silver",
"sink",
"sister",
"sisters",
"size",
"skate",
"skin",
"skirt",
"sky",
"slave",
"sleep",
"sleet",
"slip",
"slope",
"smash",
"smell",
"smile",
"smoke",
"snail",
"snails",
"snake",
"snakes",
"sneeze",
"snow",
"soap",
"society",
"sock",
"soda",
"sofa",
"son",
"song",
"songs",
"sort",
"sound",
"soup",
"space",
"spade",
"spark",
"spiders",
"sponge",
"spoon",
"spot",
"spring",
"spy",
"square",
"squirrel",
"stage",
"stamp",
"star",
"start",
"statement",
"station",
"steam",
"steel",
"stem",
"step",
"stew",
"stick",
"sticks",
"stitch",
"stocking",
"stomach",
"stone",
"stop",
"store",
"story",
"stove",
"stranger",
"straw",
"stream",
"street",
"stretch",
"string",
"structure",
"substance",
"sugar",
"suggestion",
"suit",
"summer",
"sun",
"support",
"surprise",
"sweater",
"swim",
"swing",
"system",
"table",
"tail",
"talk",
"tank",
"taste",
"tax",
"teaching",
"team",
"teeth",
"temper",
"tendency",
"tent",
"territory",
"test",
"texture",
"theory",
"thing",
"things",
"thought",
"thread",
"thrill",
"throat",
"throne",
"thumb",
"thunder",
"ticket",
"tiger",
"time",
"tin",
"title",
"toad",
"toe",
"toes",
"tomatoes",
"tongue",
"tooth",
"toothbrush",
"toothpaste",
"top",
"touch",
"town",
"toy",
"toys",
"trade",
"trail",
"train",
"trains",
"tramp",
"transport",
"tray",
"treatment",
"tree",
"trees",
"trick",
"trip",
"trouble",
"trousers",
"truck",
"trucks",
"tub",
"turkey",
"turn",
"twig",
"twist",
"umbrella",
"uncle",
"underwear",
"unit",
"use",
"vacation",
"value",
"van",
"vase",
"vegetable",
"veil",
"vein",
"verse",
"vessel",
"vest",
"view",
"visitor",
"voice",
"volcano",
"volleyball",
"voyage",
"walk",
"wall",
"war",
"wash",
"waste",
"watch",
"water",
"wave",
"waves",
"wax",
"way",
"wealth",
"weather",
"week",
"weight",
"wheel",
"whip",
"whistle",
"wilderness",
"wind",
"window",
"wine",
"wing",
"winter",
"wire",
"wish",
"woman",
"women",
"wood",
"wool",
"word",
"work",
"worm",
"wound",
"wren",
"wrench",
"wrist",
"writer",
"writing",
"yak",
"yam",
"yard",
"yarn",
"year",
"yoke",
"zebra",
"zephyr",
"zinc",
"zipper",
"zoo",
];
struct ID<'a> {
first: &'a str,
second: &'a str,
third: &'a str,
}
impl<'a> ID<'a> {
fn hostname(&self, base_domain: &str) -> String {
format!(
"{}-{}-{}.{}",
self.first, self.second, self.third, base_domain
)
}
}
fn get_random_id() -> ID<'static> {
use rand::{rngs::ThreadRng, thread_rng, Rng};
let mut rng: ThreadRng = thread_rng();
let first: usize = rng.gen_range(0..LEN);
let mut second: usize;
let mut third: usize;
loop {
second = rng.gen_range(0..LEN);
if second != first {
break;
}
}
loop {
third = rng.gen_range(0..LEN);
if third != first && second != third {
break;
}
}
let first = WORDLIST[first];
let second = WORDLIST[second];
let third = WORDLIST[third];
ID {
first,
second,
third,
}
}
pub fn get_random_subdomain(s: &Settings) -> String {
let id = get_random_id();
id.hostname(&s.page.base_domain)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_subdomains() {
// test random ID
let id = get_random_id();
assert_ne!(id.first, id.second);
assert_ne!(id.first, id.third);
assert_ne!(id.third, id.second);
// test ID::hostname
let delimiter = "foobar21312";
assert_eq!(
id.hostname(delimiter),
format!("{}-{}-{}.{delimiter}", id.first, id.second, id.third,)
);
}
}

View File

@ -14,42 +14,42 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::path::Path;
use std::sync::Arc;
use actix_web::{
body::{BoxBody, EitherBody},
dev::ServiceResponse,
error::ResponseError,
http::StatusCode,
};
use mktemp::Temp;
use serde::Serialize;
use crate::ctx::api::v1::auth::{Login, Register};
use crate::ctx::api::v1::pages::AddSite;
use crate::ctx::Ctx;
use crate::errors::*;
use crate::page::Page;
use crate::settings::Settings;
use crate::*;
pub async fn get_data() -> (Temp, Arc<Ctx>) {
pub const REPO_URL: &str = "https://github.com/mCaptcha/website/";
pub const BRANCH: &str = "gh-pages";
pub async fn get_ctx() -> (Temp, Arc<Ctx>) {
// mktemp::Temp is returned because the temp directory created
// is removed once the variable goes out of scope
let mut settings = Settings::new().unwrap();
let tmp_dir = Temp::new_dir().unwrap();
println!("[log] Test temp directory: {}", tmp_dir.to_str().unwrap());
let mut pages = Vec::with_capacity(settings.pages.len());
for page in settings.pages.iter() {
let name = Path::new(&page.path).file_name().unwrap().to_str().unwrap();
let path = tmp_dir.as_path().join(name);
let page = Page {
path: path.to_str().unwrap().to_string(),
secret: page.secret.clone(),
branch: page.branch.clone(),
repo: page.repo.clone(),
domain: "mcaptcha.org".into(),
};
pages.push(Arc::new(page));
}
settings.pages = pages;
let page_base_path = tmp_dir.as_path().join("base_path");
settings.page.base_path = page_base_path.to_str().unwrap().into();
settings.init();
println!("[log] Initialzing settings again with test config");
settings.init();
(tmp_dir, Ctx::new(settings))
(tmp_dir, Ctx::new(settings).await)
}
#[allow(dead_code, clippy::upper_case_acronyms)]
@ -58,18 +58,20 @@ pub struct FORM;
#[macro_export]
macro_rules! post_request {
($uri:expr) => {
test::TestRequest::post().uri($uri)
actix_web::test::TestRequest::post().uri($uri)
};
($serializable:expr, $uri:expr) => {
test::TestRequest::post()
actix_web::test::TestRequest::post()
.uri($uri)
.insert_header((actix_web::http::header::CONTENT_TYPE, "application/json"))
.set_payload(serde_json::to_string($serializable).unwrap())
};
($serializable:expr, $uri:expr, FORM) => {
test::TestRequest::post().uri($uri).set_form($serializable)
actix_web::test::TestRequest::post()
.uri($uri)
.set_form($serializable)
};
}
@ -107,24 +109,33 @@ macro_rules! delete_request {
)
.await
};
($app:expr, $route:expr, $cookies:expr, $serializable:expr, FORM) => {
test::call_service(
&$app,
test::TestRequest::delete()
.uri($route)
.set_form($serializable)
.cookie($cookies)
.to_request(),
)
.await
};
}
#[macro_export]
macro_rules! get_app {
("APP") => {
actix_web::App::new()
.app_data($crate::get_json_err())
.wrap(actix_web::middleware::NormalizePath::new(
actix_web::middleware::TrailingSlash::Trim,
))
.configure($crate::routes::services)
};
// ($settings:ident) => {
// test::init_service(get_app!("APP", $settings))
// };
($ctx:expr) => {
test::init_service(get_app!("APP").app_data($crate::WebData::new($ctx.clone())))
actix_web::test::init_service(
actix_web::App::new()
.app_data($crate::get_json_err())
.wrap($crate::get_identity_service(&$ctx.settings))
.wrap(actix_web::middleware::NormalizePath::new(
actix_web::middleware::TrailingSlash::Trim,
))
.configure($crate::services)
.app_data($crate::WebData::new($ctx.clone())),
)
};
}
@ -149,3 +160,132 @@ macro_rules! check_status {
}
};
}
#[macro_export]
macro_rules! get_cookie {
($resp:expr) => {
$resp.response().cookies().next().unwrap().to_owned()
};
}
impl Ctx {
/// register and signin utility
pub async fn register_and_signin(
&self,
name: &str,
email: &str,
password: &str,
) -> (Login, ServiceResponse<EitherBody<BoxBody>>) {
self.register_test(name, email, password).await;
self.signin_test(name, password).await
}
pub fn to_arc(&self) -> Arc<Self> {
Arc::new(self.clone())
}
/// register utility
pub async fn register_test(&self, name: &str, email: &str, password: &str) {
let app = get_app!(self.to_arc()).await;
// 1. Register
let msg = Register {
username: name.into(),
password: password.into(),
confirm_password: password.into(),
email: email.into(),
};
println!("{:?}", msg);
let resp = actix_web::test::call_service(
&app,
post_request!(&msg, crate::V1_API_ROUTES.auth.register).to_request(),
)
.await;
if resp.status() != StatusCode::OK {
let resp_err: ErrorToResponse = actix_web::test::read_body_json(resp).await;
panic!("{}", resp_err.error);
}
}
/// signin util
pub async fn signin_test(
&self,
name: &str,
password: &str,
) -> (Login, ServiceResponse<EitherBody<BoxBody>>) {
let app = get_app!(self.to_arc()).await;
// 2. signin
let creds = Login {
login: name.into(),
password: password.into(),
};
let signin_resp = actix_web::test::call_service(
&app,
post_request!(&creds, V1_API_ROUTES.auth.login).to_request(),
)
.await;
assert_eq!(signin_resp.status(), StatusCode::OK);
(creds, signin_resp)
}
/// pub duplicate test
pub async fn bad_post_req_test<T: Serialize>(
&self,
name: &str,
password: &str,
url: &str,
payload: &T,
err: ServiceError,
) {
let (_, signin_resp) = self.signin_test(name, password).await;
let cookies = get_cookie!(signin_resp);
let app = get_app!(self.to_arc()).await;
let resp = actix_web::test::call_service(
&app,
post_request!(&payload, url)
.cookie(cookies.clone())
.to_request(),
)
.await;
assert_eq!(resp.status(), err.status_code());
let resp_err: ErrorToResponse = actix_web::test::read_body_json(resp).await;
//println!("{}", txt.error);
assert_eq!(resp_err.error, format!("{}", err));
}
/// bad post req test without payload
pub async fn bad_post_req_test_witout_payload(
&self,
name: &str,
password: &str,
url: &str,
err: ServiceError,
) {
let (_, signin_resp) = self.signin_test(name, password).await;
let app = get_app!(self.to_arc()).await;
let cookies = get_cookie!(signin_resp);
let resp = actix_web::test::call_service(
&app,
post_request!(url).cookie(cookies.clone()).to_request(),
)
.await;
assert_eq!(resp.status(), err.status_code());
let resp_err: ErrorToResponse = actix_web::test::read_body_json(resp).await;
//println!("{}", resp_err.error);
assert_eq!(resp_err.error, format!("{}", err));
}
pub async fn add_test_site(&self, owner: String) -> Page {
let msg = AddSite {
repo_url: REPO_URL.into(),
branch: BRANCH.into(),
owner,
};
self.add_site(msg).await.unwrap()
}
}

36
src/utils.rs Normal file
View File

@ -0,0 +1,36 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::Settings;
use std::path::{Path, PathBuf};
/// Get random string of specific length
pub(crate) fn get_random(len: usize) -> String {
use rand::{distributions::Alphanumeric, rngs::ThreadRng, thread_rng, Rng};
use std::iter;
let mut rng: ThreadRng = thread_rng();
iter::repeat(())
.map(|()| rng.sample(Alphanumeric))
.map(char::from)
.take(len)
.collect::<String>()
}
pub(crate) fn get_website_path(s: &Settings, hostname: &str) -> PathBuf {
Path::new(&s.page.base_path).join(hostname)
}

1
static/cache/img/svg/eye-off.svg vendored Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="52" height="52" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="icon" class="feather feather-eye-off"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path><line x1="1" y1="1" x2="23" y2="23"></line></svg>

After

Width:  |  Height:  |  Size: 470 B

1
static/cache/img/svg/eye.svg vendored Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="52" height="52" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="icon" class="feather feather-eye"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>

After

Width:  |  Height:  |  Size: 326 B

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="{{ assets.css }}" />
<title>{% block title %} {% endblock %} | LibrePages</title>
</head>
<body class="default-body">
<header>{% block nav %} {% endblock %}</header>
{% block main %} {% endblock %}
{% include "footer" %}
</body>
</html>

View File

@ -0,0 +1,6 @@
{% if error %}
<div class="error_container">
<h3 class="error-title">ERROR: {{ error.title }}</h3>
<p class="error-message">{{ error.reason }}</p>
</div>
{% endif %}

View File

@ -0,0 +1,37 @@
<footer>
<div class="footer__container">
<div class="footer__column">
<span class="license__conatiner">
<a class="license__link" rel="noreferrer" href="/docs" target="_blank"
>Docs</a
>
</div>
<div class="footer__column">
<a
href="https://librepages.org"
class="footer__link"
target="_blank"
rel="noopener"
title="Project Homepage"
>
Home
</a>
<div class="footer__column-divider">|</div>
<a href="mailto:{{ footer.support_email }}" class="footer__link"
>Support</a
>
<div class="footer__column-divider">|</div>
<a
class="footer__link"
href="{{ footer.source_code }}"
target="_blank"
rel="noopener"
title="Source Code"
>
v{{ footer.version }}-{{ footer.git_hash }}
</a>
</div>
</div>
</footer>

View File

@ -0,0 +1,28 @@
<nav class="nav__container">
<input type="checkbox" class="nav__toggle" id="nav__toggle" />
<div class="nav__header">
<a class="nav__logo-container" href="{{page.dash.home}}">
<p class="nav__home-btn">LibrePages</p>
</a>
<label class="nav__hamburger-menu" for="nav__toggle">
<span class="nav__hamburger-inner"></span>
</label>
</div>
<div class="nav__spacer"></div>
<div class="nav__link-group">
<div class="nav__link-container">
<a class="nav__link" rel="noreferrer" href="{{ page.dash.site.add }}">New Site</a>
</div>
<div class="nav__link-container">
<a class="nav__link" rel="noreferrer" href="{{ page.dash.forgejo_webhook.list }}">Webhooks</a>
</div>
<div class="nav__link-container">
<a class="nav__link" rel="noreferrer" href="{{ page.auth.logout }}">Log out</a>
</div>
</div>
</nav>

View File

@ -0,0 +1,18 @@
<nav class="nav__container">
<input type="checkbox" class="nav__toggle" id="nav__toggle" />
<div class="nav__header">
<a class="nav__logo-container" href="/">
<p class="nav__home-btn">LibrePages</p>
</a>
<label class="nav__hamburger-menu" for="nav__toggle">
<span class="nav__hamburger-inner"></span>
</label>
</div>
<div class="nav__spacer"></div>
<div class="nav__link-group">
{% block nav_links %} {% endblock %}
</div>
</nav>

View File

@ -0,0 +1,26 @@
<nav class="nav__container">
<input type="checkbox" class="nav__toggle" id="nav__toggle" />
<div class="nav__header">
<a class="nav__logo-container" href="{{ page.dash.home }}">
<p class="nav__home-btn">LibrePages</p>
</a>
<label class="nav__hamburger-menu" for="nav__toggle">
<span class="nav__hamburger-inner"></span>
</label>
</div>
<div class="nav__spacer"></div>
<div class="nav__link-group">
<div class="nav__link-container">
<a class="nav__link" rel="noreferrer" href="https://docs.librepages.org">Docs</a>
</div>
<div class="nav__link-container">
<a class="nav__link" rel="noreferrer" href="{{ page.auth.login }}">Login</a>
</div>
<div class="nav__link-container">
<a class="nav__link" rel="noreferrer" href="{{ page.auth.register }}">Register</a>
</div>
</div>
</nav>

View File

@ -0,0 +1,112 @@
@import "../../sass/_link";
header {
z-index: 5;
position: sticky;
top: 0;
background-color: #fff;
}
.nav__container {
display: flex;
flex-direction: row;
box-sizing: border-box;
width: 100%;
padding-top: 5px;
border-bottom: 1px solid rgb(211, 211, 211);
}
.nav__home-btn {
font-weight: bold;
// font-family: monospace, monospace;
margin: auto;
margin-left: 10px;
}
.nav__hamburger-menu {
display: none;
}
.nav__spacer--small {
width: 100px;
margin: auto;
}
.nav__spacer {
flex: 4;
margin: auto;
}
.nav__logo-container {
display: inline-flex;
text-decoration: none;
}
.nav__logo-container:hover {
@include a_hover;
}
.nav__toggle {
display: none;
}
.nav__logo {
display: inline-flex;
margin: auto;
padding: 5px;
width: 40px;
}
@mixin nav__link-group {
flex: 1.5;
list-style: none;
display: flex;
flex-direction: row;
align-items: center;
align-self: center;
margin: auto;
text-align: center;
}
.nav__link-group {
@include nav__link-group;
}
.nav__link-group--small {
@include nav__link-group;
flex: 0.5;
margin-right: 10px;
}
@mixin nav__link-container {
display: flex;
padding: 10px;
height: 100%;
margin: auto;
}
.nav__link-container {
@include nav__link-container;
}
.nav__link-container--action {
@include nav__link-container;
background-color: green;
padding: 15px;
.nav__link {
color: white !important;
}
}
.nav__link {
text-decoration: none;
color: black !important;
font-weight: 600;
font-size: 14px;
}
.nav__link:hover {
@include a_hover;
}

View File

@ -0,0 +1,141 @@
//@import '../_vars';
$hamburger-menu-animation: 0.4s ease-out;
$nav__hamburger-inner-height: 1.3px;
.nav__container {
flex-direction: column;
}
.nav__header {
display: flex;
flex-direction: row;
min-width: 100%;
justify-content: space-between;
}
.nav__link-group,
.nav__link-group--small {
position: sticky;
flex-direction: column;
margin: auto;
align-items: center;
width: 100%;
// background-color: $light-blue;
}
.nav__link-container--action {
background-color: #fff;
.nav__link {
color: #000 !important;
}
}
@mixin nav__link-container {
border-bottom: 1px dashed rgba(55, 55, 55, 0.4);
width: 70%;
}
.nav__link-container {
@include nav__link-container;
}
.nav__link-container--action {
@include nav__link-container;
}
.nav__link-container:last-child {
border-bottom: none;
}
.nav__link {
margin: auto;
}
.nav__hamburger-menu {
display: inline-block;
width: 50px;
height: 50px;
}
.nav__spacer {
display: none;
}
.nav__link-group {
margin-right: auto;
}
.nav__toggle:not(:checked) ~ .nav__link-group, .nav__link-group--small {
max-height: 0;
transition: max-height $hamburger-menu-animation;
overflow: hidden;
}
.nav__toggle:checked ~ .nav__link-group, .nav__toggle:checked ~ .nav__link-group--small {
max-height: 500px;
transition: max-height $hamburger-menu-animation;
}
.nav__toggle:checked ~ .nav__header {
.nav__hamburger-inner::after {
width: 24px;
bottom: $nav__hamburger-inner-height;
transform: rotate(-90deg);
transition: bottom 0.1s ease-out,
transform 0.22s cubic-bezier(0.215, 0.61, 0.355, 1) 0.12s,
width 0.1s ease-out;
}
.nav__hamburger-inner::before {
top: 0;
opacity: 0;
transition: top 0.1s ease-out, opacity 0.1s ease-out 0.12s;
}
.nav__hamburger-inner {
transform: rotate(225deg);
transition-delay: 0.12s;
transition-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
}
}
.nav__hamburger-inner::after {
bottom: -7px;
transition: bottom 0.1s ease-in 0.25s,
transform 0.22s cubic-bezier(0.55, 0.055, 0.675, 0.19),
width 0.1s ease-in 0.25s;
}
.nav__hamburger-inner::after,
.nav__hamburger-inner::before {
content: "";
display: block;
}
.nav__hamburger-inner::before {
top: -7px;
transition: top 0.1s ease-in 0.25s, opacity 0.1s ease-in;
}
.nav__hamburger-inner {
top: 50%;
margin: auto;
transition-duration: 0.22s;
transition-timing-function: cubic-bezier(0.55, 0.055, 0.675, 0.19);
}
.nav__hamburger-inner,
.nav__hamburger-inner::after,
.nav__hamburger-inner::before {
width: 24px;
height: $nav__hamburger-inner-height;
position: relative;
// background: $dark-black;
background: #000;
}
.nav__hamburger-menu,
.nav__hamburger-inner {
display: block;
}

View File

@ -0,0 +1,5 @@
@mixin fullscreen {
height: 100vh;
min-height: 500px;
// max-height: 800px;
}

View File

@ -0,0 +1,4 @@
@mixin a_hover {
color: rgb(0, 86, 179);
text-decoration: underline;
}

View File

@ -0,0 +1,79 @@
@import "../_link";
footer {
display: block;
color: #333;
font-size: 0.7rem;
padding: 0;
margin: 0;
}
.footer__container {
width: 100%;
padding: 0;
justify-content: space-between;
margin: auto;
display: flex;
flex-direction: row;
overflow: hidden;
}
@mixin footer__column-base {
list-style: none;
display: flex;
margin: auto 50px;
align-items: center;
flex: 2.5;
}
.footer__column {
@include footer__column-base;
}
.footer__column--center {
@include footer__column-base;
margin: auto;
flex-direction: column;
align-items: center;
flex: 2;
}
.footer__column:last-child {
justify-content: flex-end;
a {
margin: 10px;
}
}
.footer__link-container {
margin: 5px;
}
.footer__link {
text-decoration: none;
}
.license__link {
display: inline;
}
.license__link:hover {
@include a_hover;
}
.footer__column-divider,
.footer__column-divider--mobile-visible,
.footer__column-divider--mobile-only {
font-weight: 500;
opacity: 0.7;
margin: 0 5px;
}
.footer__column-divider--mobile-only {
display: none;
}
.footer__icon {
margin: auto 5px;
height: 20px;
}

View File

@ -0,0 +1,52 @@
$footer-font-size: 0.44rem;
footer {
font-size: $footer-font-size;
}
.footer__container {
display: grid;
grid-template-rows: repeat(3, 100%);
align-items: center;
margin: auto;
justify-content: center;
}
.footer__link {
font-size: 0.5rem;
}
.license__conatiner,
.license__link {
text-align: center;
}
@mixin footer__column-base {
margin: 0 auto;
display: flex;
padding: 0;
}
.footer__column:first-child {
grid-row-start: 3;
flex-direction: row;
}
.footer__column:last-child {
grid-row-start: 2;
}
.footer__column {
@include footer__column-base;
align-self: flex-end;
}
.footer__column--center {
@include footer__column-base;
align-self: flex-start;
}
.footer__column-divider--mobile-only {
margin: 0 3px;
font-size: 9.9px;
}

70
templates/defaults.scss Normal file
View File

@ -0,0 +1,70 @@
* {
padding: 0;
margin: 0;
//font-family: "Inter UI", -apple-system, BlinkMacSystemFont, "Roboto",
// "Segoe UI", Helvetica, Arial, sans-serif;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
}
a {
text-decoration: none;
}
a:hover, button:hover {
cursor: pointer;
}
a,
a:visited {
color: rgb(0, 86, 179);
}
.base {
min-height: 100vh;
display: flex;
flex-direction: column;
width: 100%;
}
.main__content-container {
display: flex;
flex-direction: column;
min-height: 100%;
justify-content: space-between;
flex: 2;
}
p,
h1,
h2,
h3,
h4,
li,
ol,
ul {
color: #333;
}
main {
width: 100%;
}
blockquote {
border-left: 0.3em solid rgba(55, 55, 55, 0.4);
margin-bottom: 16px;
//padding-left: 20px;
padding: 0 1em;
color: #707070;
p,
h1,
h2,
h3,
h4,
li,
ol,
ul {
color: inherit;
}
}

14
templates/main.scss Normal file
View File

@ -0,0 +1,14 @@
@import "defaults.scss";
@import "pages/auth/sass/main.scss";
@import "pages/auth/sass/form/main.scss";
@import "pages/dash/main.scss";
@import "pages/dash/sites/main.scss";
@import "components/sass/footer/main.scss";
@import "components/nav/sass/main.scss";
.default-body {
display: flex;
@include fullscreen;
flex-direction: column;
justify-content: space-between;
}

3
templates/mobile.scss Normal file
View File

@ -0,0 +1,3 @@
@import "components/sass/footer/mobile.scss";
@import "pages/auth/sass/mobile.scss";
@import "components/nav/sass/mobile.scss";

View File

@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="{{ assets.css }}" />
<title>LibrePages</title>
</head>
<body class="auth__body">
<header>{% include "pub_nav" %}</header>
<main class="index-banner__container">
<section class="index-banner">
<div class="index-banner__content-container">
<h1 class="index-banner__title">
Easiest way to deploy websites
</h1>
<p class="index-banner__tagline">
JAMstack platform with focus on privacy and speed
</p>
<ul class="index-banner__features-list">
<li class="index-banner__features">
<b>Seamless Git Integration</b> making migration
easy
</li>
<li class="index-banner__features">
<b>Pull Request Previews</b> to verify changes
before deployment
</li>
<li class="index-banner__features">
<b>Server-less form submissions</b> to collect data
from visitors
</li>
<li class="index-banner__features">
<b>Global CDN</b> for high-speed access from across
the world
</li>
<li class="index-banner__features">
<b>
100%
<a
href="https://www.gnu.org/philosophy/free-sw.html"
>Free Software</a
> </b
>: deploy your own instance
</li>
<li class="index-banner__features">
25% of the income dedicated to sustain Free Software
dependencies
</li>
</ul>
</div>
</section>
<section class="index-banner__logo-container">
{% block login %} {% endblock %}
</section>
</main>
{% include "footer" %}
</body>
</html>

View File

@ -0,0 +1,44 @@
{% extends 'authbase' %}
{% block login %}
<h2>Sign In</h2>
<form action="{{ page.auth.login }}" method="POST" class="auth-form" accept-charset="utf-8">
{% include "error_comp" %}
<label class="auth-form__label" for="login">
Username or Email
<input
class="auth-form__input"
name="login"
autofocus
required
id="login"
type="text"
{% if payload.username %}
value={{ payload.username }}
{% endif %}
/>
</label>
<label class="auth-form__label" for="password">
Password
<input
class="auth-form__input"
name="password"
required
id="password"
type="password"
{% if payload.password %}
value={{ payload.password }}
{% endif %}
/>
</label>
<div class="auth-form__action-container">
<a href="">Forgot password?</a>
<button class="auth-form__submit" type="submit">Login</button>
</div>
</form>
<p class="auth-form__alt-action">
New to LibrePages?
<a href="{{ page.auth.register }}">Create an account </a>
</p>
{% endblock %}

View File

@ -0,0 +1,73 @@
{% extends 'authbase' %}
{% block title_name %}Sign Up {% endblock %}
{% block login %}
<h2>Sign Up</h2>
<form action="{{ page.auth.register }}" method="POST" class="auth-form" accept-charset="utf-8">
{% include "error_comp" %}
<label class="auth-form__label" for="username">
Username
<input
class="auth-form__input"
autofocus
name="username"
required
id="username"
type="text"
{% if payload.username %}
value={{ payload.username }}
{% endif %}
/>
</label>
<label class="auth-form__label" for="email">
Email
<input
class="auth-form__input"
name="email"
id="email"
type="email"
{% if payload.email %}
value={{ payload.email }}
{% endif %}
/>
</label>
<label class="auth-form__label" for="password">
password
<input
class="auth-form__input"
name="password"
required
id="password"
type="password"
{% if payload.password %}
value={{ payload.password }}
{% endif %}
/>
</label>
<label class="auth-form__label" for="confirm_password">
Re-enter Password
<input
class="auth-form__input"
name="confirm_password"
required
id="confirm_password"
type="password"
{% if payload.confirm_password %}
value={{ payload.confirm_password }}
{% endif %}
/>
</label>
<div class="auth-form__action-container">
<a href="/forgot-password">Forgot password?</a>
<button class="auth-form__submit" type="submit">Sign Up</button>
</div>
</form>
<p class="auth-form__alt-action">
Already have an account?
<a href="{{ page.auth.login }}"> Login </a>
</p>
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More