mirror of
https://github.com/jhbruhn/notes2ics.git
synced 2025-03-14 20:55:49 +00:00
initial commit
This commit is contained in:
commit
497f7027d2
8 changed files with 2627 additions and 0 deletions
122
.github/workflows/release.yaml
vendored
Normal file
122
.github/workflows/release.yaml
vendored
Normal file
|
@ -0,0 +1,122 @@
|
|||
name: Create and publish a Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['main']
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform:
|
||||
- linux/amd64
|
||||
- linux/arm/v7
|
||||
- linux/arm64
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
-
|
||||
name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
-
|
||||
name: Cache Docker layers
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-multi-${{ matrix.platform }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-multi-${{ matrix.platform }}-buildx
|
||||
-
|
||||
name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
#tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
# Note the mode=max here
|
||||
# More: https://github.com/moby/buildkit#--export-cache-options
|
||||
# And: https://github.com/docker/buildx#--cache-tonametypetypekeyvalue
|
||||
cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new
|
||||
-
|
||||
name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
-
|
||||
name: Upload digest
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: digests
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
- name: Move cache
|
||||
run: |
|
||||
rm -rf /tmp/.buildx-cache
|
||||
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build
|
||||
steps:
|
||||
-
|
||||
name: Download digests
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: digests
|
||||
path: /tmp/digests
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
-
|
||||
name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
-
|
||||
name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
||||
-
|
||||
name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/target
|
2065
Cargo.lock
generated
Normal file
2065
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
20
Cargo.toml
Normal file
20
Cargo.toml
Normal file
|
@ -0,0 +1,20 @@
|
|||
[package]
|
||||
name = "notes2ics"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
warp = "0.3"
|
||||
reqwest = { version = "0.11", features = ["native-tls-vendored", "cookies"] }
|
||||
anyhow = "1"
|
||||
icalendar = { version = "0.15", features = ["chrono-tz"] }
|
||||
chrono = "0"
|
||||
dyn-fmt = "0.4.0"
|
||||
quick-xml = { version = "0.31", features = ["serialize"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tracing = "0.1"
|
||||
envconfig = "0.10"
|
37
Dockerfile
Normal file
37
Dockerfile
Normal file
|
@ -0,0 +1,37 @@
|
|||
FROM --platform=$BUILDPLATFORM rust:1.70 as cross
|
||||
ARG TARGETARCH
|
||||
COPY docker/platform.sh .
|
||||
RUN ./platform.sh # should write /.platform and /.compiler
|
||||
RUN rustup target add $(cat /.platform)
|
||||
RUN apt update && apt-get install -y unzip $(cat /.compiler)
|
||||
|
||||
WORKDIR ./ics-adapter
|
||||
ADD . ./
|
||||
RUN cargo build --release --target $(cat /.platform)
|
||||
RUN cp ./target/$(cat /.platform)/release/ics-adapter /ics-adapter.bin # Get rid of this when build --out is stable
|
||||
|
||||
|
||||
FROM debian:buster-slim
|
||||
ARG APP=/usr/src/app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y ca-certificates tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV TZ=Etc/UTC \
|
||||
APP_USER=appuser
|
||||
|
||||
RUN groupadd $APP_USER \
|
||||
&& useradd -g $APP_USER $APP_USER \
|
||||
&& mkdir -p ${APP}
|
||||
|
||||
COPY --from=cross /ics-adapter.bin ${APP}/ics-adapter
|
||||
|
||||
RUN chown -R $APP_USER:$APP_USER ${APP}
|
||||
|
||||
USER $APP_USER
|
||||
WORKDIR ${APP}
|
||||
|
||||
CMD ["./ics-adapter"]
|
12
docker-compose.yml
Normal file
12
docker-compose.yml
Normal file
|
@ -0,0 +1,12 @@
|
|||
services:
|
||||
notes2ics:
|
||||
image: ghcr.io/jbruhn/notes2ics:main
|
||||
environment:
|
||||
NOTES_USERNAME: <username>
|
||||
NOTES_PASSWORD: <password>
|
||||
NOTES_HOST: domino2.something.de
|
||||
HOST: 0.0.0.0
|
||||
PORT: 3000
|
||||
ports:
|
||||
- 3000:3000
|
||||
# http://localhost:3000/calendar/<username>?startDays=-7&endDays=31&filterInvites=true
|
19
docker/platform.sh
Executable file
19
docker/platform.sh
Executable file
|
@ -0,0 +1,19 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Used in Docker build to set platform dependent variables
|
||||
|
||||
case $TARGETARCH in
|
||||
|
||||
"amd64")
|
||||
echo "x86_64-unknown-linux-gnu" > /.platform
|
||||
echo "" > /.compiler
|
||||
;;
|
||||
"arm64")
|
||||
echo "aarch64-unknown-linux-gnu" > /.platform
|
||||
echo "gcc-aarch64-linux-gnu" > /.compiler
|
||||
;;
|
||||
"arm")
|
||||
echo "armv7-unknown-linux-gnueabihf" > /.platform
|
||||
echo "gcc-arm-linux-gnueabihf" > /.compiler
|
||||
;;
|
||||
esac
|
351
src/main.rs
Normal file
351
src/main.rs
Normal file
|
@ -0,0 +1,351 @@
|
|||
use dyn_fmt::AsStrFormatExt;
|
||||
use reqwest::ClientBuilder;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::convert::Infallible;
|
||||
use std::sync::Arc;
|
||||
use tracing_subscriber::fmt::format::FmtSpan;
|
||||
use warp::{Filter, Rejection, Reply};
|
||||
use envconfig::Envconfig;
|
||||
|
||||
const DATETIME_FORMAT: &str = "%Y%m%dT%H%M%S,00%#z";
|
||||
|
||||
const LOGIN_URL: &str = "names.nsf?Login";
|
||||
const CALENDAR_URL: &str = "mail/{}.nsf/($Calendar)?ReadViewEntries&Count=-1&KeyType=time";
|
||||
// StartKey
|
||||
// UntilKey
|
||||
|
||||
#[derive(Envconfig, Clone)]
|
||||
struct Config {
|
||||
#[envconfig(from = "NOTES_USERNAME")]
|
||||
pub notes_username: String,
|
||||
|
||||
#[envconfig(from = "NOTES_PASSWORD")]
|
||||
pub notes_password: String,
|
||||
|
||||
#[envconfig(from = "NOTES_HOST")]
|
||||
pub notes_host: String,
|
||||
|
||||
#[envconfig(from = "PORT", default = "3000")]
|
||||
pub port: u16,
|
||||
|
||||
#[envconfig(from = "HOST", default = "0.0.0.0")]
|
||||
pub host: String,
|
||||
}
|
||||
|
||||
async fn login(client: &reqwest::Client, host: &str, username: &str, password: &str) -> anyhow::Result<()> {
|
||||
let mut params = HashMap::new();
|
||||
params.insert("Username", username);
|
||||
params.insert("Password", password);
|
||||
let target = format!("/{}", CALENDAR_URL.format(&[username]));
|
||||
//let target = format!("https://{}{}", host, &target.as_str());
|
||||
params.insert("RedirectTo", &target.as_str());
|
||||
let response = client
|
||||
.post(format!("https://{}/{}", host, LOGIN_URL))
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let logged_in = response?.status() == 302; // redirecting to target ressource
|
||||
if logged_in {
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!("Invalid credentials!")
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_calendar(
|
||||
client: &reqwest::Client,
|
||||
host: &str,
|
||||
username: &str,
|
||||
days_back: i64,
|
||||
days_ahead: i64,
|
||||
) -> anyhow::Result<String> {
|
||||
let start =
|
||||
(chrono::Utc::now() + chrono::Duration::days(days_back)).format("%Y%m%dT000001,00Z");
|
||||
let end = (chrono::Utc::now() + chrono::Duration::days(days_ahead)).format("%Y%m%dT000001,00Z");
|
||||
let target = CALENDAR_URL.format(&[username]);
|
||||
let target = format!(
|
||||
"https://{}/{}&StartKey={}&UntilKey={}",
|
||||
host,
|
||||
&target.as_str(),
|
||||
start,
|
||||
end
|
||||
);
|
||||
let response = client.get(target).send().await?;
|
||||
let text = response.text().await?;
|
||||
let logged_in = text.contains("viewentries");
|
||||
if logged_in {
|
||||
Ok(text)
|
||||
} else {
|
||||
anyhow::bail!("Not logged in perhaps?")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CustomReject(anyhow::Error);
|
||||
|
||||
impl warp::reject::Reject for CustomReject {}
|
||||
|
||||
pub(crate) fn custom_reject(error: impl Into<anyhow::Error>) -> warp::Rejection {
|
||||
warp::reject::custom(CustomReject(error.into()))
|
||||
}
|
||||
|
||||
async fn handler(
|
||||
calendar_user: String,
|
||||
client: Arc<reqwest::Client>,
|
||||
config: Config,
|
||||
params: HashMap<String, String>,
|
||||
) -> anyhow::Result<impl Reply, Rejection> {
|
||||
let days_back = params
|
||||
.get("startDays")
|
||||
.and_then(|x| x.parse().ok())
|
||||
.unwrap_or(-14);
|
||||
let days_ahead = params
|
||||
.get("endDays")
|
||||
.and_then(|x| x.parse().ok())
|
||||
.unwrap_or(31);
|
||||
let filter_invites = params
|
||||
.get("filterInvites")
|
||||
.and_then(|x| x.parse().ok())
|
||||
.unwrap_or(true);
|
||||
let calendar_str = load_calendar(&client, &config.notes_host, &calendar_user, days_back, days_ahead).await;
|
||||
if let Ok(calendar_str) = calendar_str {
|
||||
tracing::info!("Got calendar for {}", calendar_user);
|
||||
Ok(parse_calendar(&calendar_str, filter_invites)
|
||||
.map_err(custom_reject)?
|
||||
.to_string())
|
||||
} else {
|
||||
tracing::info!("Logging in again");
|
||||
login(&client, &config.notes_host, &config.notes_username, &config.notes_password)
|
||||
.await
|
||||
.map_err(custom_reject)?;
|
||||
tracing::info!("Logged in, loading calendar");
|
||||
Ok(parse_calendar(
|
||||
&load_calendar(&client, &config.notes_host, &calendar_user, days_back, days_ahead)
|
||||
.await
|
||||
.map_err(custom_reject)?,
|
||||
filter_invites,
|
||||
)
|
||||
.map_err(custom_reject)?
|
||||
.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn with_client(
|
||||
client: Arc<reqwest::Client>,
|
||||
) -> impl Filter<Extract = (Arc<reqwest::Client>,), Error = Infallible> + Clone {
|
||||
warp::any().map(move || client.clone())
|
||||
}
|
||||
|
||||
fn with_config(
|
||||
config: Config
|
||||
) -> impl Filter<Extract = (Config,), Error = Infallible> + Clone {
|
||||
warp::any().map(move || config.clone())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let filter = std::env::var("RUST_LOG")
|
||||
.unwrap_or_else(|_| "notes2ics=debug,tracing=info,warp=debug".to_owned());
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_span_events(FmtSpan::CLOSE)
|
||||
.init();
|
||||
|
||||
let config = Config::init_from_env().unwrap();
|
||||
|
||||
let client = ClientBuilder::new()
|
||||
.cookie_store(true)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.unwrap();
|
||||
let client = Arc::new(client);
|
||||
let get_calendar_route = warp::path!("calendar" / String)
|
||||
.and(with_client(client))
|
||||
.and(with_config(config.clone()))
|
||||
.and(warp::query::<HashMap<String, String>>())
|
||||
.and_then(handler);
|
||||
warp::serve(
|
||||
warp::get()
|
||||
.and(get_calendar_route)
|
||||
.with(warp::trace::request()),
|
||||
)
|
||||
.run((config.host.parse::<std::net::Ipv4Addr>().unwrap(), config.port))
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct CalendarDocument {
|
||||
#[serde(rename = "@timestamp")]
|
||||
_timestamp: String,
|
||||
#[serde(rename = "$value", default)]
|
||||
viewentries: Vec<ViewEntry>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct ViewEntry {
|
||||
#[serde(rename = "$value", default)]
|
||||
entry_datas: Vec<EntryData>,
|
||||
#[serde(rename = "@unid", default)]
|
||||
uid: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct EntryData {
|
||||
#[serde(rename = "@name")]
|
||||
name: String,
|
||||
#[serde(rename = "$value")]
|
||||
data: DataList,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
enum DataList {
|
||||
#[serde(rename = "datetimelist")]
|
||||
DateTimeList {
|
||||
#[serde(rename = "$value")]
|
||||
datetimes: Vec<DataList>,
|
||||
},
|
||||
#[serde(rename = "numberlist")]
|
||||
NumberList {
|
||||
#[serde(rename = "$value")]
|
||||
_number: Vec<DataList>,
|
||||
},
|
||||
#[serde(rename = "textlist")]
|
||||
TextList {
|
||||
#[serde(rename = "$value")]
|
||||
texts: Vec<DataList>,
|
||||
},
|
||||
#[serde(rename = "datetime")]
|
||||
DateTime(String),
|
||||
#[serde(rename = "number")]
|
||||
Number(i32),
|
||||
#[serde(rename = "text")]
|
||||
Text(String),
|
||||
#[serde(rename = "$text")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl DataList {
|
||||
fn try_first_text(&self, skip: usize) -> anyhow::Result<String> {
|
||||
Ok(match self {
|
||||
Self::Text(t) if skip == 0 => t.to_string(),
|
||||
Self::TextList { texts } => texts
|
||||
.iter()
|
||||
.skip(skip)
|
||||
.next()
|
||||
.ok_or(anyhow::anyhow!("empty"))?
|
||||
.try_first_text(0)?,
|
||||
_ => anyhow::bail!("Wrong format"),
|
||||
})
|
||||
}
|
||||
|
||||
fn try_first_datetime(&self, skip: usize) -> anyhow::Result<String> {
|
||||
Ok(match self {
|
||||
Self::DateTime(v) if skip == 0 => v.to_string(),
|
||||
Self::DateTimeList { datetimes } => datetimes
|
||||
.iter()
|
||||
.skip(skip)
|
||||
.next()
|
||||
.ok_or(anyhow::anyhow!("empty"))?
|
||||
.try_first_datetime(0)?,
|
||||
_ => anyhow::bail!("Wrong format"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_calendar(text: &str, filter_invites: bool) -> anyhow::Result<icalendar::Calendar> {
|
||||
let doc: CalendarDocument = quick_xml::de::from_str(text)?;
|
||||
|
||||
let mut calendar = icalendar::Calendar::default();
|
||||
|
||||
for view_entry in doc.viewentries {
|
||||
let name = view_entry
|
||||
.entry_datas
|
||||
.iter()
|
||||
.filter(|x| x.name == "$147")
|
||||
.next()
|
||||
.ok_or(anyhow::anyhow!("No summary"))
|
||||
.and_then(|x| x.data.try_first_text(0));
|
||||
let location = view_entry
|
||||
.entry_datas
|
||||
.iter()
|
||||
.filter(|x| x.name == "$147")
|
||||
.next()
|
||||
.ok_or(anyhow::anyhow!("No summary"))
|
||||
.and_then(|x| x.data.try_first_text(1));
|
||||
let start = view_entry
|
||||
.entry_datas
|
||||
.iter()
|
||||
.filter(|x| x.name == "$144")
|
||||
.next()
|
||||
.ok_or(anyhow::anyhow!("No Start"))
|
||||
.and_then(|x| x.data.try_first_datetime(0));
|
||||
let end = view_entry
|
||||
.entry_datas
|
||||
.iter()
|
||||
.filter(|x| x.name == "$146")
|
||||
.next()
|
||||
.ok_or(anyhow::anyhow!("No End"))
|
||||
.and_then(|x| x.data.try_first_datetime(0));
|
||||
let day = view_entry
|
||||
.entry_datas
|
||||
.iter().filter(|x| x.name == "$134")
|
||||
.next()
|
||||
.ok_or(anyhow::anyhow!("No Day"))
|
||||
.and_then(|x| x.data.try_first_datetime(0));
|
||||
|
||||
use icalendar::Component;
|
||||
use icalendar::EventLike;
|
||||
let entry: anyhow::Result<icalendar::Event> = (|| {
|
||||
let day = day?;
|
||||
let mut new_entry = icalendar::Event::new();
|
||||
new_entry.uid(&format!("{}-{}", &view_entry.uid, &day)); // Repeat events
|
||||
// get the same uid,
|
||||
// but we don't know
|
||||
// about the repeats here
|
||||
// so every date is unique.
|
||||
let summary = name?;
|
||||
if filter_invites && summary.starts_with("Einladung: ") {
|
||||
anyhow::bail!("Not accepted");
|
||||
}
|
||||
new_entry.summary(&summary);
|
||||
if let Ok(start) = start {
|
||||
let end = end?;
|
||||
new_entry.starts::<icalendar::CalendarDateTime>(
|
||||
chrono::DateTime::parse_from_str(&start, DATETIME_FORMAT)?
|
||||
.with_timezone(&chrono::Utc)
|
||||
.into(),
|
||||
);
|
||||
new_entry.ends::<icalendar::CalendarDateTime>(
|
||||
chrono::DateTime::parse_from_str(&end, DATETIME_FORMAT)?
|
||||
.with_timezone(&chrono::Utc)
|
||||
.into(),
|
||||
);
|
||||
} else {
|
||||
// Seems to be all day
|
||||
|
||||
new_entry.starts::<chrono::NaiveDate>(
|
||||
chrono::DateTime::parse_from_str(&day, DATETIME_FORMAT)?
|
||||
.with_timezone(&chrono::Utc)
|
||||
.date_naive()
|
||||
.into(),
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
if let Ok(location) = location {
|
||||
new_entry.location(&location);
|
||||
}
|
||||
|
||||
Ok(new_entry.done())
|
||||
})();
|
||||
if let Ok(entry) = entry {
|
||||
calendar.push(entry);
|
||||
} else {
|
||||
tracing::warn!("Invalid ViewEntry: {:?}", entry);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(calendar)
|
||||
}
|
Loading…
Reference in a new issue